Compare commits

..
Author SHA1 Message Date
Meta Vens
63a677bea9 Correct one comment still naming the deleted To.SchemaId tiebreak
Soul found this while confirming batch 8. The Survivor-5 header in the same
file was updated when the component was deleted and this one was not, so it
described a comparator that no longer exists.

Self edited this directly rather than dispatching Hands for a single stale
sentence. Naming that because Self editing code is a boundary worth saying
out loud when it is crossed, even for a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:21:52 +02:00
Meta Vens
492dd0e754 R-BA: delete the unreachable to.schemaId tiebreak from both edge comparators
Soul could not construct a selection where To.SchemaId decides the
EdgesFor/page_edges order once From, Role and To.RecordKey already
tie, but flagged that as a claim, not a proof - the same shape of
argument that was wrong twice before on this comparator (R-AT).

Traced instead of asserted: TryResolveReferenceTarget (C#) and
resolve_reference_target (Rust) resolve a reference purely from the
citer's schema, the role, and the record key, against the evaluation's
own fixed candidate index (byKey/by_key) - no edge- or caller-specific
input steers which candidate wins. Once From and Role tie (pinning the
citer's schema and the declared target type/leaves) and To.RecordKey
also ties, the resolved row - schema included - is forced identical by
construction, in both runtimes, every time. No mutation can distinguish
it because no input can, so it was deleted rather than recorded as
"equivalent": a component nothing can distinguish is a line every
future reader must reason about for no behavioural gain.

Deletes the To.Descriptor.SchemaId/to.schema_id() ThenBy/then_with from
CultNetSelectionEvaluator.EdgesFor and selection.rs's page_edges sort,
and updates the comments (in-code and the Survivor 5 test block) that
described the old five-key chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:55 +02:00
Meta Vens
cda28fa2ae R-AZ: mirror the all-absent Edge vector into the C# parity test
The 246-byte all-absent Edge vector (SM-10) existed only on the Rust
side (selection_wire_parity.rs's CSHARP_EDGE_HEX). The all-absent
header gets incidental cover because it is nested inside the 376-byte
envelope test; the edge has no such cover, so a C# edge encoder that
resumed omitting a null payload/payloadEncoding would still pass every
other test here - exactly the asymmetry R-AP existed to kill.

Adds EdgeAllAbsentMatchesTheConstantCommittedIntoTheRustTest, asserting
the same constant against MessagePackSerializer.Serialize. Flipping one
hex digit of RustEdgeAllAbsentHex fails the new test only, confirming
it is not vacuous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:08:42 +02:00
Meta Vens
35a450e32f R-AX: land the whole-message parity vector, and extend it past all-absent
Soul had this green in the rig at 376 bytes and called it "blocked on
unbuilt page-assembly ownership" - the wrong thing: only the two
*evaluators* producing the same page from the same rows needed that move
(a separate claim, R-AR); the two *encoders* producing identical bytes for
the same page value never did. Land the whole
cultnet.snapshot_response_raw.v1 envelope, byte-identical between Rust's
encode_cultnet_message_to_vec and C#'s CultNetSnapshotResponseRawV1Message,
on both runtimes.

The existing header/edge vectors also proved nothing about tags
(string[] vs Vec<String>) or payload (byte[] vs serde_bytes): both were
absent at every value pinned. Add a header with tags populated and an
edge with a payload populated, captured the same way against a real
MessagePack C# 3.1.7 run on Yggdrasil. Neither diverged - the two
mappings agree byte-for-byte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:40:52 +02:00
Meta Vens
edfe9db59d R-AV: give the typed-selection parity claim a home in runtime-parity-scope.md
Section 9 of docs/cultnet-selection-cut.md pointed here for the claim;
this file never mentioned typed selection at all. Add the row, in the
words from fix batch 7's R-AY ruling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:40:31 +02:00
Meta Vens
d784dbca7e D10b: note the breaking registration change in the error message
A shape that used to register and work - a data member aliased Owner
beside a reference member named Owner - is now refused by D10b (R-AL).
Soul's merge-gate third pass flagged this as a breaking change with no
note; the error message and its owning comment now say so.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:39:51 +02:00
Meta Vens
22d0722d5a R-AT: fix the C# self-citer fixture's naming so it actually defeats push-order luck
The zebra/alpha pairing that kills Rust's drop-both-to-components mutant
does not kill it in C#: EvaluateAll concatenates citesEdges before
citedEdges (the opposite of cultnet_rs's single cited-then-cites Vec), so
that pairing's push order already matched the correct sort order here too
- just for the opposite reason. Swap to alpha (self) / zebra (target),
the pairing that actually disagrees with this runtime's own push order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:38:27 +02:00
Meta Vens
0bdcf6fd55 R-AT: add missing MessagePack using the C# self-citer fixture needs
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:35:37 +02:00
Meta Vens
c9ba53cfe1 R-AT: pin the edge comparator's tied-from-and-role survivor in C#
The same live mutant Soul found in Rust (dropping to.schemaId/to.recordKey
from the edge comparator) survives in C# too - both runtimes share the
comparator's shape, not a rule proving it. Land the C# mirror of Soul's
self-citing-peer counterexample from the rig, renamed so the self edge's
push order and the correct sort order actually disagree (the rig's own
"hub"/"other" naming left the drop-both mutation surviving by coincidence
of push order matching alphabetical order - see the Rust commit on this
branch for the construction).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:34:44 +02:00
Meta Vens
7581f38db1 R-AT: pin the row and edge comparators' recordKey/to survivors in Rust
Soul's merge-gate third pass (SM-7) found three live mutants: dropping
to.schemaId/to.recordKey from the edge comparator (both runtimes) and
dropping recordKey from Rust's row comparator (named a pass ago, never
touched). Hands' earlier argument that no selection ties from+role while
leaving to free to vary was false - a selection carrying both cites and
cited mixes their edges into one bucket, and a self-citing peer produces
exactly that tie. Take the counterexample from Soul's rig rather than
rebuilding it, and add the missing row-comparator recordKey tiebreak test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:32:06 +02:00
Meta Vens
94707ed8ed R-AP: nil-not-omitted for Edge/RawDocumentHeader/SelectionDocumentRecord/SelectionPage
Decided the payload shape: a map with camelCase keys, fields in declared
order, every optional field always present and nil when absent - never
omitted. C#'s [MessagePackObject]/[Key("name")] map formatter already does
this by default (confirmed byte-for-byte below); Rust's `to_vec_named`
encoding for these four types used `skip_serializing_if = "Option::is_none"`,
which omits the key instead - the gap the map cited ("C# writes nine fields
with nils, Rust omits absent ones").

Removes skip_serializing_if from Edge, RawDocumentHeader,
SelectionDocumentRecord and SelectionPage, keeping #[serde(default)] for
decode-side robustness. Selection (the request type) is untouched - out of
this ruling's scope, and the map already confirmed the request side agrees
between runtimes.

Adds tests/selection_wire_parity.rs: RawDocumentHeader and Edge, each
carrying several absent optionals, encoded via to_vec_named and compared
byte-for-byte against hex captured from a real MessagePack C# 3.1.7 run
(same pattern as error_message.rs's csharp_hex vectors) - both now agree
exactly.

What is NOT done, and why: R-AP also called for "Select gains projection,
so the thing the vectors test is the thing that decides." In C#, projection
(header vs document, ToRawRecord/ToEdge/FromRecord) is owned entirely by
CultNetDocumentRegistry.SelectPage/SelectAll (GameCult.Networking), not by
CultNetSelectionEvaluator.Select/Page - the evaluator returns raw Rows/
EdgeMatches and knows nothing about a document's payload serializer, which
only the registry's bindings own. Moving page assembly into the evaluator
without threading a caller-supplied serializer (the same pattern ordinalOf/
shardIdOf already use) would give a schema-generic evaluator per-binding
wire knowledge it should not own; threading a serializer callback in is
buildable but changes SelectPage/SelectAll's own signature and every
caller, plus the parity-vector harness's construction - a materially larger
change than this pass's remaining scope. No vector yet exercises the real
CultNetSnapshotResponseRawV1Message/select_page output byte-for-byte; only
the two nested wire types in isolation. Flagged for the operator per the
brief's own stop condition.

R-AQ, updated: type-level byte parity for the nested wire shapes (Edge,
RawDocumentHeader) is now real and vector-tested. Whole-message byte parity
for cultnet.snapshot_response_raw.v1 is still not - the smaller claim (this
cut's page bytes agree only where R-AH's fixed-shape rule reaches, and
projection ownership is still split between the evaluator and the registry)
is the true one until "Select gains projection" actually lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:33:02 +02:00
Meta Vens
62ebba4ab5 R-AO: shared vector for two-in-target shared-key resolution
Adds citer-both-in-target to the shared fixture, the first row in file (and
row-order) to cite "dup-key" through Design - both leaf_a and leaf_b at that
key are in Design's declared target, unlike citer_narrow's narrow_ref above
it (only ever one in-target candidate). Adds the matching vector case to
both runtimes' case lists and regenerates both written vector files
(CULTNET_WRITE_VECTORS=1), so cross-runtime agreement on this shape is
pinned by the parity check, not just asserted per-runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:18:11 +02:00
Meta Vens
4323e2280a R-AN/R-AO: Rust edge-order and shared-key regression tests
R-AN: the existing edge-order test ties From and To across its two edges,
so it only pins from.record_key as sole discriminator - dropping role
entirely still survives (Soul's SM-4). Two new tests isolate the other two
components of EdgesFor's comparator: role (same citer, same target, two
roles - Citation.role: None matches any reference) and from.schema_id (two
different-schema citers sharing one record key and role). Both confirmed to
kill their own dropped-clause mutation by hand (reverted after).

R-AO: shared_key_with_two_in_target_candidates_resolves_to_whichever_the_-
caller_listed_first pins "first row in the caller's order wins" - R-AF's
existing vector only covered one-in/one-out. Confirmed to kill a
last-wins mutation by hand (reverted after). Also extends the shared vector
case list with shared_key_design_resolves_the_first_in_target_candidate_-
in_row_order (paired with the fixture/vector commit).

To.SchemaId/To.Key were re-checked per Self's ruling: within any one
page_position bucket, `cites` always ties every edge's To to the query's
own single target, and `cited` always ties it to the anchor row's own
identity - the only way for To to vary at a tied page_position is a mixed
cites+cited hop on one row, which always ties or untangles via From first.
No construction was found where To is the sole discriminator; the earlier
"equivalent within one hop direction" claim holds. No code change, no
artificial test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:17:59 +02:00
Meta Vens
f01c0b6112 R-AL/R-AM/R-AO: C# regression tests for the fix batch
- R-AL: DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration
  pins D10b's registration refusal. ReferenceMembersExcludesADataMember...
  pins the restored evaluator guard directly (hand-built descriptor via
  reflection, since D10b now closes the registry door this shape used to
  slip through).
- R-AM: CultNetDatabaseServer_SnapshotV1Handler_CatchesAnUntypedFault... and
  RudpCultNetSchemaServer_DispatchBackstop_LogsEveryUntypedHandlerFault pin
  the handler's catch and the backstop's logging, each against a real
  untyped fault.
- R-AO: Evaluator_CitedWithTwoInTargetCandidates_ResolvesToWhicheverThe-
  CallerListedFirst pins "first row in the caller's order wins" when two
  in-target rows share a key, in both row orders.

Every test in this commit was checked against a hand-reverted mutation of
its own production fix and confirmed to fail before being confirmed to pass
against the real fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:17:21 +02:00
Meta Vens
77cd9649ec R-AM: snapshot handler and dispatch backstop answer/log untyped faults
HandleSnapshotRequestV1Async only caught the three typed selection
exceptions, unlike its siblings HandleShardLogRequestAsync/HandlePutAsync,
which catch Exception and answer a CultNetErrorMessage. An untyped fault
(e.g. a null Selection) escaped into the RUDP dispatch backstop, which
swallowed it with no log - the same backstop that hid SM-6's
ArgumentNullException regression for a whole batch.

- HandleSnapshotRequestV1Async now catches Exception like its siblings and
  answers a CultNetErrorMessage.
- RudpCultNetSchemaServer gets a Logger (ILogger/NullLogger, same pattern as
  Server/Client), and DispatchAsync's backstop always logs the fault it
  swallows, unwrapping DynamicInvoke's TargetInvocationException wrapper so
  the logged message is the handler's real exception, not the generic
  invocation-wrapper text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:15:43 +02:00
Meta Vens
375b6177ae R-AL: restore ReferenceMembers' IsReference guard and fix D10's blind spot
CultNetSelectionEvaluator.ReferenceMembers dropped the `!member.IsReference`
guard on the premise that a data member's role string could never coincide
with a reference member's alias - false. CultCache's D10 only rejects a
collision between two *aliased* members; an unaliased data member's bare
name was never checked against another member's alias. A data member named
`Owner` plus a reference member aliased `Owner` registered fine, then faulted
every hop through it: ReferenceMembers yielded the data member, ReferencesOf
matched it to the reference member's edges by role string, and
`member.TargetType!` was null.

Two independent fixes, both regression-tested:
- Restore the guard, delete the false comment. Pinned directly by hand-
  building the colliding descriptor via reflection (bypassing the registry,
  since D10b below now refuses that shape at the only production door).
- D10b: registration now refuses a plain member whose bare name collides
  with a reference member's alias in the reference-role namespace
  (IndexAlias ?? MemberName), not just alias-vs-alias.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:15:33 +02:00
Meta Vens
fc51e9a408 R-AI: pin render_f64's sign-bit extraction on a negative value too
Same gap as render_f32's sign-bit test (previous commit): render_f64's
sign extraction has the identical shape and the identical coincidence
(-1.0_f64/-2.0_f64's bit 0 happens to be 0, matching their real sign
bit's positive-looking value under a wrong-direction shift).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:18:49 +02:00
Meta Vens
82895ec866 R-AI: pin render_f32's sign-bit extraction on a negative value
Every existing render_f32 test (including the two added in the previous
commit) used a positive value, so a mutant that shifts the sign-bit read
the wrong direction (bits << 31 instead of bits >> 31, reading bit 0
instead of bit 31) went unnoticed: 1.0_f32 and 2.0_f32 both happen to
have bit 0 equal to 0, same as their real (positive) sign bit, so the
mutant computed the same "not negative" answer by coincidence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:18:49 +02:00
Meta Vens
a24beda0e3 R-AI: pin render_f32's subnormal branch and implicit leading bit directly
The first canonical_number mutation pass (previous commit) closed every
survivor except four in render_f32 (biased_exponent == 0 flipped to !=,
the subnormal exponent's -149 losing its sign, and the normal path's
implicit-bit | flipped to ^/&). The existing round-trip tests parse the
rendered string back through f32::parse and compare bits, which can
still agree with a wrong bit-extraction when the wrong value happens to
parse back to the bits it was carelessly derived from; none of them
assert on the rendered string's shape or magnitude directly.

Add a subnormal-shape test (2^-149 is "0." plus 149 fraction digits, not
an astronomical integer and not one exponent off) and a normal-path test
where the fraction bits are identical to zero's (1.0 and 2.0) so only
the implicit leading bit the | folds in carries the value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:18:49 +02:00
Meta Vens
68af08a5b2 R-AI: pin canonical_number's grammar and bignum helpers directly
cargo-mutants over selection.rs found every branch mutation in
is_canonical surviving (the Q-J door was reachable only through the
handful of spellings validation_refuses_non_canonical_number_spellings
happened to cover), plus decompose/pad_right/canonicalize_decimal_digits/
big_mul_small/big_to_most_significant_first each replaceable with a
trivial stub while the suite stayed green.

Add tests that call is_canonical, decompose, pad_right, compare,
canonicalize_decimal_digits, big_mul_small and big_to_most_significant_first
directly (the nested test module already reaches private helpers via
super::), pinning exact output at the inputs that exercise each branch,
comparison and carry step named in the mutant list rather than only the
few spellings the field-validation door happened to already cover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:18:49 +02:00
Meta Vens
3c4f83b038 R-AG/R-AK: checked C# cursor-length arithmetic, a dispatch backstop, and the redundant guard
R-AG. ReadLengthPrefixedFields tested pos + length > bytes.Length in int
math; a length prefix near int.MaxValue overflowed the unchecked add,
slipped past the guard, and Encoding.UTF8.GetString threw
ArgumentOutOfRangeException instead of this method returning null the
way every other malformed shape already does. Promotes to long before
the add, which cannot itself overflow (both operands stay far under
long's range), so the guard sees the true sum.

CultNetRudpSchemaServer.DispatchAsync gains a catch around the handler
invocation: HandleSnapshotRequestV1Async already answers
cursor_invalid/selection_invalid/reference_outside_target for its three
typed exceptions, but nothing stopped an untyped one from an
unanticipated bug reaching DynamicInvoke's caller and stalling the poll
loop for every other peer. Answers nothing on the wire (an exception
this generic carries no typed refusal to report) and drops the one
malformed dispatch, the same as an unregistered handler already does.

Hostile-cursor tests (CultNetSelectionFixBatchTests.cs) feed
CultNetSelectionCursor.Parse a length prefix near int.MaxValue (C#'s own
proven overflow), near usize::MAX (Rust's proven overflow, fed here too
per the ruling), wider than any integer type, and negative-looking -
each asserted to throw the typed CultNetSelectionCursorException.

R-AK. Deletes ReferenceMembers's `!member.IsReference` guard.
CultCache.ReferencesOf (CultCache.cs:262-278) already searches only its
own reference members for a role match and returns empty for anything
else; index aliases are unique per descriptor, so a data member's own
role string can never coincide with a reference member's. Verified
before deleting: traced ReferencesOf's role lookup directly rather than
trusting the ruling's premise. A second copy of the same exclusion here
was duplicate authority, not a second decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:44:09 +02:00
Meta Vens
dd08be4f32 R-AF/R-AG/R-AJ: Rust parity, hostile-cursor, and ordering-composition tests
R-AF: FixtureRow's references (and every fixture/hand-built row that
constructs them) carry a bare target key now, matching Row::references's
new contract. Adds citer_narrow() (the shared fixture's real schema id,
distinct from this file's own local NARROW_CITER) to the fixture
machinery, and two direct behavioural tests
(shared_key_resolves_to_the_row_inside_the_declared_target_in_either_row_order,
shared_key_refuses_when_the_only_candidate_is_outside_the_declared_target)
pinning both shapes from Soul's table at the select() level, plus a
shared-fixture vector case for the accept shape and an
evaluation_refusal_cases entry (expected_evaluation_refusal, read by the
matching C# addition) for the refuse shape.

Rewrites cites_refuses_a_row_carrying_an_unrelated_edge_outside_its_declared_target
(renamed cites_refuses_an_out_of_target_edge_under_the_same_queried_role):
its original shape queried one role while expecting refusal from an
edge under an unrelated role, a stronger guarantee than C#'s own R-T(F15)
test gives (same role, two elements) - found because it broke once
matches_citation's role-filter order was fixed to match C#. The new
version mirrors C#'s actual test shape.

R-AG: hostile cursor tests feeding Cursor::parse a length prefix near
usize::MAX (Rust's own proven overflow), near i32::MAX (C#'s proven
overflow, fed here too per the ruling - neither side's tests are
evidence about the other), a prefix wider than any integer type, and a
negative-looking prefix - all under std::panic::catch_unwind, asserting
a typed CursorInvalid and never a panic.

R-AJ: edge_order_is_from_key_before_role_when_both_vary_at_the_same_anchor
(a both-hops selection with a hub/other citer pair whose keys and roles
are deliberately anti-correlated, so a Role-first mutant and the real
rule disagree) and
row_tiebreak_orders_by_schema_id_before_record_key_when_both_vary_at_a_tied_ordinal
(the same anti-correlation trick applied to order_rows, computed against
the real leaf_a/leaf_b ids at runtime).

FixtureRow gains #[derive(Debug)] - needed for an assert message on the
new evaluation-refusal-case writer, harmless elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:43:46 +02:00
Meta Vens
b9db8702d2 R-AF/R-AJ: C# parity vectors and the ordering-composition tests they needed
R-AF: registers ParityCiterNarrow (citer_narrow's C# type, [CultIndex
"narrow_ref"] so its role matches the fixture/Rust spelling), adds
Keys-scoped vectors pinning both shapes from Soul's table - a shared key
where the only candidate is out of target refuses evaluation-time
(expectedEvaluationRefusal, a narrow addition to the vector schema
distinct from the door-only expectedRefusalField - not R-Z/R-AH's
page-bytes harness), and one where an in-target row shares a key with an
out-of-target row and must still resolve.

Landing these exposed a real, pre-existing cross-runtime scope
difference in R-W unrelated to R-AF: Rust checked every declared
reference on a row regardless of the citation's requested role;
C#'s ReferenceMembers(descriptor, citation.Role) narrows by role first.
Fixed on the Rust side (previous commit); hop_by_role_cites_exact_id and
cites_target_by_name_alias keep an explicit Keys scope here anyway as
belt-and-suspenders.

Also corrects S-12's last stale comment: cites_target_by_name_alias's
"Confirmed cross-runtime defect" note was already false - the alias port
fixed it - and still called it confirmed.

R-AJ: CultNetSelectionSurvivorTests.cs's row-tiebreak fixture
(Evaluator_BreaksAnOrdinalTieBySchemaIdThenRecordKeyInBothDirections) was
degenerate - probed by hand-reverting the source and swapping the
tiebreak's two ThenBy calls, it still passed, because whether the old
3-row fixture discriminated the swap depended on which of SelLeafA's/
SelLeafB's real schema ids happened to sort first, and on this repo's
ids it didn't. Rewritten to assign row keys relative to the schema ids
discovered at runtime (low-sorting schema gets the high-sorting key and
vice versa) so the two candidate orderings are always the exact reverse
of each other; reprobed the same mutant afterward and it now fails.

Adds a direct EdgesFor test (constructing FullEvaluation/EdgeMatch by
hand) pinning "From.Key before Role" in the five-key edge tiebreak - the
two existing EdgesFor tests each tie one of those two components, so
neither could tell a Role-first mutant from the rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:43:25 +02:00
Meta Vens
9bc50830c1 R-AF/R-AJ: shared parity fixture rows and regenerated vector files
The fixture had zero duplicate record keys across its ten rows and no
ordinal tied across two different schemas - both shapes this fix batch's
rulings needed and neither runtime's parity vectors ever covered.

R-AF adds a "citer_narrow" schema (real SHA-256 id, computed the same way
CultDocumentRegistry does - a probe run against a type matching the
eventual C# type's exact namespace/nesting confirmed it) whose one
reference targets leaf_a only, plus rows for both shapes from Soul's
table: a lone out-of-target row at a key (refuses), and two rows sharing
a key where only one is in-target (accepts, resolving the in-target one
regardless of row order).

Also drops "targetSchema" from every reference entry: it was never
anything but documentation (SelectionParityVectorTests.BuildDocument and
cultnet-rs's build_row both already read only targetKey) and keeping a
field the wire does not actually have would contradict the rule R-AF just
established.

R-AJ adds a tied-ordinal pair across leaf_a and leaf_b, with keys chosen
opposite to the real schema ids (probed) on purpose, so a schemaId-first
order and a recordKey-first order are forced to disagree regardless of
which real id happens to sort first - see the row-tiebreak test this
enables.

selection-vectors.cs-written.json and selection-vectors.rs-written.json
are regenerated from the new fixture (CULTNET_WRITE_VECTORS=1 on both
sides) and cross-checked in both directions locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:43:02 +02:00
Meta Vens
323ec3867c R-AF/R-AG: Rust resolves references by the wire's actual rule; checked cursor arithmetic
R-AF. Probed the real wire bytes a stored CultRecordRef<T> produces
(GameCult.Caching.MessagePack.CultRecordRefFormatter<T>.Serialize writes
only value.Key.Value - a probe against a real document confirmed the
serialized "Design" field is the bare msgpack string "a-eq", nothing
else). The wire carries no schema for a reference; C#'s rule was right
and Rust's exact-(schema,key) lookup assumed a schema id the edge never
actually carries.

Row::references() now returns (role, target_record_key, payload) - a
bare key, matching C#'s ReferencesOf. matches_citation/build_incoming_index
resolve through the new resolve_reference_target, which mirrors C#'s
TryResolveReferenceTarget: search every row sharing the key (by_key is
now keyed by record_key alone, grouping rows across schemas) for the one
whose schema falls inside the declared leaf set, refusing deterministically
(by schema id, code-point order) when none do. Folds D9's leaf-set check
into resolution itself rather than a second ensure_within_declared_target
pass - deleted, since it also dropped the old bypass-on-empty-leaves
behaviour C# never had (empty leaves always refuses when a candidate
exists).

Also moved the citation's own role filter before resolution in
matches_citation (mirroring C#'s ReferenceMembers(descriptor,
citation.Role), which narrows to that role's members before R-W's
per-edge check ever runs) - found while landing R-AF's vectors: Rust
was checking every declared reference a row carries regardless of the
requested role, a stronger and different guarantee than the C#
reference actually gives.

R-AG. read_length_prefixed_fields used unchecked usize addition
(pos + len) to bound a length prefix parsed from the wire; a hostile
prefix near usize::MAX overflowed and panicked. Uses checked_add now,
returning None (which Cursor::parse already turns into a typed
CursorInvalid) the same way every other malformed shape here already
does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:42:41 +02:00
Meta Vens
d7f73af3b6 Rust R-AB: re-sync the vendored cultnet.error.schema.json after rebase
Rebasing onto the C# side's landed R-X work (fix batch 4) picked up
changes to contracts/cultnet/cultnet.error.schema.json that predate the
vendored copy this branch added. The drift test added for R-AB caught
it immediately: re-copied, no other vendored schema or the vendored
fixture differed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:41:59 +02:00
Meta Vens
97bc2076b1 Rust R-AB: vendor the parity fixture too, so cargo-mutants' baseline
actually runs

The schema_discovery.rs include_str! escape was not the only one:
tests/selection.rs's fixture() read
../../contracts/cultnet/interop/selection-vectors.fixture.json, the
same three-levels-up path shape, and 37 of 45 tests panicked on it
under cargo-mutants' isolated copy - confirmed on Yggdrasil after the
schema_discovery.rs fix alone still landed "cargo test failed in an
unmutated tree, so no mutants were tested".

selection-vectors.fixture.json is a checked-in input (not a generated
artifact), so it gets the same treatment as the 22 schemas: a
byte-identical vendored copy under
packages/cultnet-rs/contracts/cultnet/interop/, a drift test
(fixture_contract_matches_the_canonical_source, same skip-when-absent
shape as schema_discovery_contracts.rs), and the loader reads the
vendored copy unconditionally.

selection-vectors.cs-written.json is different in kind: it is the C#
reference's own generated output, produced only by a real cross-runtime
checkout running SelectionParityVectorTests with
CULTNET_WRITE_VECTORS=1. There is nothing to vendor - an isolated
Rust-only copy can never satisfy that test's premise regardless of what
this crate does with its own files. That one test now skips (with an
explanation) when the canonical contracts/ tree is entirely absent,
rather than panicking and failing the whole suite over a cross-runtime
artifact the isolated copy cannot produce by construction.

Verified locally by hiding contracts/ entirely and rerunning the full
suite (217 tests across every binary): unchanged pass count, same as
with it present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:29:24 +02:00
Meta Vens
4914e560d5 S-12: correct the shared fixture's alias prose, now that both runtimes
share one alias rule

selection-vectors.fixture.json's "//aliases" comment still called the
two alias forms a cross-runtime defect ("no candidate string that
resolves through both"). That was true before the Rust alias port; it
is not true now. Both runtimes resolve a schema alias the same one way
(strip a trailing .vN, compare the remainder against the descriptor's
declared SchemaName): nameAlias resolves through both, and hashAlias -
kept in the fixture on purpose - is refused as unmatched by both, since
neither runtime's production call sites produce or accept it.

Prose-only change to this shared fixture file, as ruled: no vector
values, schema ids, or row data touched.

Also recorded for Self's map: cultnet-rs's alias port (R-E) does not
carry SchemaVersion or CompatibleSchemaIds - no Rust call site needs
either, so schema_alias::matches folds them away and agrees with the C#
rule only on the subset it actually uses (candidate/id/name comparison,
not version or compatibility-list matching).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:29:24 +02:00
Meta Vens
1abd453115 Rust R-AB: make cultnet-rs self-contained enough for cargo-mutants
schema_discovery.rs embedded the 22 contract schemas via
include_str!("../../../contracts/cultnet/*.schema.json"), three levels
above src/ and outside packages/cultnet-rs entirely. There is no
workspace Cargo.toml tying this crate to its siblings, so cargo-mutants
treats this package's own directory as the whole tree it copies - every
one of those paths escaped it, and the unmutated baseline build failed
on all 22 with "No such file or directory". There was no mutation
evidence for the Rust half of this cut at all.

Fix: vendor byte-identical copies of the 22 schemas under
packages/cultnet-rs/contracts/cultnet/, git-tracked so they travel with
the package, and repoint every include_str! at the vendored copy. A
build script staging files into OUT_DIR was the other option named for
this rule, but it does not actually solve the problem: the script would
still have to read from outside the package to find its source, and
would fail the same way inside cargo-mutants' copy. Vendoring is
"another route that keeps every source path inside the package."

Vendoring duplicates files this repo treats as canonical
(contracts/cultnet/*.schema.json, shared across every runtime per
docs/cultnet-selection-cut.md section 1), so a new test,
schema_discovery_contracts.rs, compares every vendored file against its
repo-root original byte for byte and fails loudly on drift. It skips
(not fails) when the canonical contracts/ tree is not present beside
the package - the isolated-copy case R-AB exists to survive - so the
check cannot reintroduce the same escape it guards against.

cargo-mutants run on the crate, at least covering this cut's diff:
report to follow with every survivor triaged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:29:24 +02:00
Meta Vens
d9bc9eb974 Rust R-Y: the cursor's HMAC covers its own body, not only the selection
Cursor::compute_digest hashed the selection alone (cursor/limit cleared),
so a caller holding one valid cursor could splice its genuine digest onto
a body naming a different asOf/ordinal/schemaId/recordKey and reuse it -
the digest never bound the cursor to the position it names.

compute_digest now takes (asOf, ordinal, schemaId, recordKey, selection,
key) and folds the body into the same length-prefixed canonical string
before hashing; mint and verify_digest pass their own body through.

Tests: an in-module unit test (cursor_tests, next to Cursor, where the
private digest field and the length-prefix helpers are reachable) splices
a genuine digest onto a rewritten ordinal and a rewritten record key and
confirms verification fails on both; an integration test confirms two
cursors minted for different positions under the same selection and key
never share a digest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:29:24 +02:00
Meta Vens
ccea1098ce Rust R-V and R-W: row identity is (schemaId, recordKey), and each hop
edge is anchored by its own direction

R-V (S-1): select()'s row index and the cited incoming index were both
keyed by record key alone, so two schemas sharing one key collided -
which row a `cites` edge resolved to, and whether a `cited` selection
counted a row as cited, depended on row order. Both indexes are now
keyed by (schemaId, recordKey), matching CultCache's per-schema key
uniqueness.

R-W (S-3), Rust half: a selection carrying both `cites` and `cited`
computed one `cited_direction` flag and applied it to every edge in the
batch, so the `cites` edges vanished whenever `cited` was also present.
Each `EdgeMatch` now carries its own `EdgeAnchor` (Citer/Citee) set at
the point it is produced, and the page filter/sort anchor each edge by
its own hop instead of one shared direction.

Tests: two schemas at one record key, in both row orders, agree on
which row a citation resolves to and which row counts as cited; a
selection with both hops returns edges from both directions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:29:24 +02:00
Meta Vens
99e366a36d R-AA follow-up: kill ComputeDigest's flag-swap survivors, pin EdgesFor's primary key
The first Stryker pass (72.18%, up from 66%) showed the named dozen mostly
dead but surfaced two related gaps the "differs" style of assertion cannot
reach on its own:

- ComputeDigest's field-number and cites.role presence flags each had an
  equality-mutation survivor (field.Number != null / selection.Cites.Role
  != null) that a same-vs-different digest comparison cannot catch: the
  mutant swaps which of two cases gets which flag value, so both cases
  still digest differently, just under swapped labels. A handful of plain
  AppendString/sb.Append deletions survived alongside it for the same
  reason - removing content that a same-vs-different pair didn't happen
  to isolate. One independently reconstructed golden HMAC value, built
  from the method's own doc comments rather than its private helpers,
  pins the whole wire shape at once: any single append or flag polarity
  wrong breaks the exact match.
- EdgesFor's OrderBy(pageRowIndex) - the primary "page-row order" key R-B
  names before "(from, role, to)" - had no test forcing page position and
  from/role/to order to disagree, so it survived alongside (and separate
  from) the five ThenBy mutants already named.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:48:10 +02:00
Meta Vens
8ee1ebaf67 R-AA: behavioural tests for the dozen real Stryker survivors
Soul's triage on 52082ec named ~12 live survivors across
CultNetSelection.cs/CultNetSelectionEvaluator.cs, each sitting on a rule
this cut exists to enforce, plus 75 uncovered mutants behind them. Each
new test drives the public selection/evaluator surface with a fixture
built to make the rule's failure observable in the answer, not in the
code's spelling - several needed new fixtures (a mixed numeric/string
alias, an index alias differing from its member name, two reference
members resolving to one target) because their absence is why the
mutant survived in the first place.

Two of the five EdgesFor order-flip mutants (To.SchemaId/To.Key) are not
addressed: within one hop direction an edge's (From, Role) already
determines its To deterministically, so no legal selection can tie
anchor+From+Role while To differs. Recorded as a likely equivalence,
left for Stryker's own verdict rather than forcing a contrived test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:37:38 +02:00
Meta Vens
b7d77b708d Fix batch 4 (R-X, S-4/S-10): the error schema matches the wire, and the validator refuses a keyword it does not implement
CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every
declared key is written on the wire, nil included - the same fact R-S
already established for the raw document header. cultnet.error.schema.json
was missing routingHint entirely and typed code/details as always-present
non-null values; Soul's P7 found real bytes failing it on exactly this.
routingHint, code and details are now `required`, with nullable types
matching CultNetErrorMessage/CultNetErrorDetails. A new test decodes real
bytes for all four refusal codes (selection_invalid, cursor_stale,
cursor_invalid, reference_outside_target) against the schema.

MiniJsonSchemaValidator:
- implements minimum, maximum, maxLength and maxItems, which were declared
  in committed schemas (fieldPredicate's number pattern, selection.limit,
  ...) but silently never checked;
- anchors `pattern` to a full match (Index==0 && Length==value.Length)
  instead of Regex.IsMatch's "search anywhere", so .NET's `$` (which,
  unlike ECMA-262's, matches immediately before one trailing newline even
  without RegexOptions.Multiline) can no longer let e.g. "5\n" pass a
  schema author wrote as "exactly one digit";
- refuses a schema node that uses a keyword this validator gives no
  constraint semantics to, instead of silently ignoring it. Running every
  committed contracts/cultnet/*.schema.json schema through this found 12
  such uses, none reached by any existing test: `allOf`/`if`/`then`/`else`
  in cultnet.database-change-raw.schema.json, and `uniqueItems` (six
  places) plus one `anyOf` in the cultmesh.verse-catalog-*.schema.json
  pair. contentEncoding, $schema/$id/$defs/title/description/default and
  the x-cult-runtime-type vendor extension are treated as metadata with no
  constraint semantics to implement, not refused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:23 +02:00
Meta Vens
b3a8f6d195 Fix batch 4 (S-9/S-11): shard-scoped asOf, and the v1 subscribe door runs before the hop refusal
S-9: CurrentAsOf() returned the maximum shard-log sequence across every
shard the database holds, but that sequence is a per-shard counter -
mixing shards' counters together could report a page as exact "as of" a
sequence a different, untouched shard advanced to while the page's own
shard sat still. CultNetDatabase gains CurrentAsOf(shardId), the watermark
of one shard's own log, and CultNetDocumentRegistry.SelectPage now scopes
asOf to the single shard EnsureSingleShard already resolves the matched
rows to, falling back to the caller-supplied value only when the selection
matches no rows.

S-11: CultNetDatabaseServer.HandleSubscribeV1Async refused a hop-bearing
selection before running the door (Validate), so a selection invalid for
an unrelated reason (an empty keys list, an undeclared index, ...) got a
hop refusal that had nothing to do with why it was actually rejected. The
door now runs first, unconditionally. The hop refusal also always named
the field "cites" even when the selection carried Cited instead; it now
names whichever hop is actually set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:12:07 +02:00
Meta Vens
e99bf46520 Fix batch 4 (R-V/R-W/S-8/R-Y): row identity is (schemaId, recordKey), and the cursor digest covers its own body
R-V (S-1): byKey and BuildIncomingIndex keyed the hop's row resolution by a
bare record key. Two schemas sharing one key collapsed into a single
dictionary slot (last writer wins), so which row a reference/citation
resolved to - and therefore whether a selection matched or refused
reference_outside_target - depended on row insertion order. byKey is now a
bare-key -> row-list index, and a shared TryResolveReferenceTarget searches
each edge's declared leaf set to resolve the one row that actually belongs
to it, deterministically regardless of order (Soul's P2, tested both ways).
EdgesFor's page-row anchor index is keyed the same way for the same reason.

R-W (S-2): EnsureWithinDeclaredTarget's equivalent check (now folded into
TryResolveReferenceTarget) now runs for every edge a reference carries,
before a citation's own key/schema filters that edge out - not only for the
edge the citation happens to ask about. A many-reference edge into a schema
outside its declared target refuses the selection even when the caller's
key filter would otherwise skip past it without ever inspecting it (Soul's
P3).

S-8: the dead `targetType ?? typeof(object)` fallback is deleted. D11
already refuses every reference shape whose target the cache cannot infer
before it can reach the evaluator, so the branch had no reachable fixture.

R-Y: the cursor's HMAC now covers its own body (asOf, ordinal, schemaId,
recordKey) as well as the selection. Before this, a forged cursor could
reuse a legitimately minted digest while rewriting the ordinal or record
key underneath it, since nothing about the body was ever authenticated
(Soul's P4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:11:54 +02:00
Meta Vens
52082ec720 Add "not" support to MiniJsonSchemaValidator
fieldPredicate's oneOf branches use `not: {required: [...]}` to make
values/number exclusive; the validator had no "not" handling at all, so it
silently ignored the keyword and FieldPredicate_ValuesAndNumberTogether_FailsItsSchema
found nothing wrong. A schema matches "not" when validating against the
subschema produces zero errors, in which case the instance must not match it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:13:53 +02:00
Meta Vens
f47060d5c2 Land fix batch 3's R-S: the schemas describe the bytes C# writes
New contracts/cultnet/cultnet.raw-document-header.schema.json for
CultNetRawDocumentHeader - it has no payload/payloadEncoding members at all,
so headers must not $ref the record schema (which required both). The
response page's own schema now lists every property CultNetSnapshotResponseRawV1Message
declares as `required` (present on the wire, nil included) rather than only
the four that happened to be non-nullable - MessagePack-CSharp's map-mode
formatter writes every declared key regardless of null, so `headers`/`documents`
are always both present, one an array and the other an explicit null by
projection. The old `oneOf: [{required:[headers]},{required:[documents]}]`
matched both branches on every real page (both keys present) and so never
validated anything; it is now `oneOf` over each key's type (array vs null),
which is mutually exclusive. cultnet.raw-document-record.schema.json gained
schemaName/schemaVersion/schemaContentHash (present on every real record, not
previously in the schema at all - any real page would have failed
additionalProperties:false on them). fieldPredicate's two oneOf branches each
gained `not: {required: [the other]}`, so `values` and `number` cannot both
be present (previously only `required` was checked, which does not exclude
the sibling key).

Added MiniJsonSchemaValidator, a deliberately small draft-2020-12 subset
validator (type/required/additionalProperties/properties/items/const/enum/
pattern/minLength/minItems/oneOf/$ref, same-document and cross-document) -
no schema-validation dependency existed in this repo, and this only needs to
cover what contracts/cultnet/*.schema.json actually use. CultNetSchemaContractTests
decodes real wire bytes from a live CultNetDatabaseServer (header projection,
document projection, and a hop with edges) through MessagePackSerializer.ConvertToJson
and checks them against the committed schema files, plus three
fieldPredicate-shape tests pinning the values/number exclusivity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:12:58 +02:00
Meta Vens
753c750e37 Fix the cursor body's length prefix to count UTF-8 bytes, not UTF-16 chars
AppendString (CultNetSelectionEvaluator.cs) wrote value.Length, .NET's UTF-16
code-unit count, but Cursor.Parse's ReadLengthPrefixedFields slices that many
bytes out of the UTF-8-encoded body. An astral character (a surrogate pair:
2 UTF-16 units, 4 UTF-8 bytes) made the two counts disagree, so a cursor
minted for a row keyed with one (Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak's
astral key) failed to parse back - caught by that existing test on Yggdrasil,
not a new one. The prefix is now Encoding.UTF8.GetByteCount(value); the
digest's canonical string never round-trips through the byte-sliced reader
(it is hashed whole), so this only had to be right for the cursor body, but
one counting rule for both is what keeps AppendString actually unambiguous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:06:16 +02:00
Meta Vens
d08fe0449f Drop the duplicate ResolveShard added in the previous commit
CultNetDatabase already had a public ResolveShard(schemaId, key) wrapping
ResolveShardInternal; the previous commit added a second one with the same
signature. CultNetDatabaseServer.CreateSelectionResponse's shardIdOf callback
uses the pre-existing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:05:10 +02:00
Meta Vens
a4cf54f4a9 Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.

Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.

R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.

Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
Meta Vens
eff62e59fb Land R-N in Rust: cultnet.error.v0 code/details, matched byte-for-byte
CultNetMessage::Error gains typed code (CultNetErrorCode) and details
(CultNetErrorDetails) fields mirroring the landed C# reference
(CultNetErrorMessage/CultNetErrorDetails, CultNetSchemaMessages.cs). The
message now encodes/decodes through the raw cultnet.schema.v0 path
(encode_raw_cultnet_schema_message/parse_raw_cultnet_schema_message)
instead of the generic serde_json::Value round trip every other
CultNetMessage variant uses, because that path's BTreeMap-backed
serde_json::Value would sort keys alphabetically and break the C#
reference's fixed schemaVersion/error/routingHint/code/details map
order - every optional key is written nil, never omitted, matching
MessagePack C#'s always-five-key formatter.

Adds the SelectionRefusal -> CultNetMessage mapping the fix-batch-3
comment in selection.rs called out as pending on the C# shape landing,
covering all four codes (selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target).

cultmesh-rs's one CultNetMessage::Error match gets an added `..` so it
still compiles against the wider variant; it does not read code/details.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:00:37 +02:00
Meta Vens
e8a3decbab Fix batch 3 tests: R-N wire decode, R-P wire-id authorization, R-T removal/cites
Four CultNetErrorMessage wire round-trip tests (decode the bytes a peer actually
receives) proving code+details for selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target. A subscription-server round trip proving snapshot and
first live change are authorized by the same wire id (R-P). A subscription-server
round trip proving a removal under a field-predicate selection is delivered with
no exception (R-T(a)). An evaluator test proving an implicitly-typed many
reference still refuses an out-of-target edge (R-T(b)).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:46:32 +02:00
Meta Vens
66b5e60b43 Fix batch 3: R-N wire refusal codes, R-P one authorizer id, R-T removal/cites gaps
R-N: cultnet.error.v0 gains additive code/details fields (contracts/cultnet).
CultNetErrorMessage grows ForSelectionInvalid/ForCursor/ForReferenceOutsideTarget
factories; both C# servers (CultNetDatabaseServer for snapshot+live,
CultNetDatabaseSubscriptionServer) use them at every existing catch site, plus
two sites that previously let the exception escape uncaught instead of reaching
the peer (CultNetDatabaseServer's v1 subscribe snapshot branch,
CultNetDatabaseSubscriptionServer's Subscribe around CreateProjectedSnapshot).
CultNetSelectionCursorException now carries AsOf/Current for cursor_stale.

R-P: CultNetDatabaseSubscriptionServer.CreateMatchedRecord (the live fast path)
authorized against descriptor.SchemaId; the snapshot path (CreateProjectedSnapshot)
already authorized against the wire id. Both now call the same
CultNetDocumentRegistry.WireSchemaId (made internal, was private) so one id
reaches the authorizer on the snapshot, live and reconcile paths alike.

R-T(a): CultNetSelectionEvaluator.MatchesSchemaKeysFields no longer reads a
Removed change's (always-null) document against Fields - it matches on
keys/schemas alone and treats a field predicate as unknown instead of throwing
TryGetIndexValue(null).

R-T(b): EnsureWithinDeclaredTarget no longer skips the out-of-target check when
TargetType is null; it checks against every registered leaf (unconstrained is a
real, checked target set, not a bypass). CultCache's ResolveReferenceTarget also
now infers the target from a many reference's CultRecordRef<T> element when no
explicit [CultReference(typeof(...))] is declared, which was the one remaining
source of a null TargetType reaching that check on a real, testable path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:46:32 +02:00
Meta Vens
c606eb38f2 Back out the guessed R-N wire shape; R-N is pending on this branch
Self's sequencing note (2026-09-22): the C# R-N pass (code/details on
cultnet.error.v0) is running separately in C:\ws3 and will report the
exact field names and code strings once it lands. Do not guess them.

Reverts the earlier Error{code,details} fields on CultNetMessage, the
SelectionRefusal::wire_code/wire_details/to_wire_message mapping, and
their five tests. R-O, R-T, R-U's hand-probes and R-R are unaffected -
none of them depend on the wire error shape. When the C# R-N commit
lands, pull it, read its exact field names and codes, and implement the
Rust mapping against those instead of a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:35:57 +02:00
Meta Vens
a312f6072c CultNet selection cut1 fix batch 3, Rust half: R-R, the v0 lowering matches the C# pre-cut server
docs/cultnet-selection-cut.md, Self's rulings for fix batch 3 (2026-09-22),
R-R: "the Rust v0 lowering must match the C# pre-cut v0 behaviour" -
recordKeys: [] answers empty, and the requested key order is kept. The
parallel C# pass landed the same correction on the reference server at
5e33d81/1afbaed/f8ba2d0 (pulled via rebase); this is the Rust side of it.

lower_v0_list (packages/cultnet-rs/src/snapshot_query.rs) used to collapse
a present-but-empty-after-trimming schemas/recordKeys list down to `None`
("no filter"), turning `recordKeys: []` into "every row" instead of the
pre-cut server's actual answer of nothing. It now returns `Some(vec![])`
for that case; the shared evaluator's matches_schema_keys_fields already
treats a present-but-empty list as "match nothing" (R-E), so no further
door/short-circuit logic was needed here - unlike the C# reference,
serve_read_only_raw_snapshot never calls validate, so there is no v1 door
for this shape to hit (the R-R fixup at 1afbaed doesn't apply to Rust).

serve_read_only_raw_snapshot also now restores the caller's requested
record-key order on the response (a stable sort by position in
record_keys), matching the pre-cut server's HashSet<string>
insertion-order iteration instead of leaking plain snapshot-source order.

Rewrote the stale test that pinned the old (wrong) "empty/blank list is no
filter" behaviour into
serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empty_or_blank_answers_empty,
and added serve_read_only_raw_snapshot_keeps_the_requested_record_key_order.
Hand-verified both by reverting each fix locally and confirming its own
test fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:32:02 +02:00
Meta Vens
5aac120a58 CultNet selection cut1 fix batch 3, Rust half: R-N/R-O/R-T source and tests
The source and test changes for the previous commit's message - split out
because the harness-deletion staged from an earlier `git rm` committed
alone before these paths were added. Same ruling, same scope: R-N's wire
code/details, R-O's keyed length-prefixed cursor, R-T's every-edge cites
check, and their behavioural tests (including the hand-verified mutants:
reverting each of the three kills its own new test).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:26:56 +02:00
Meta Vens
5cf31d1e87 CultNet selection cut1 fix batch 3, Rust half: R-N/R-O/R-T, retire the Rust mutation harness
docs/cultnet-selection-cut.md, Self's rulings for fix batch 3 (2026-09-22),
the Rust-owned share:

- R-N: cultnet.error.v0 gains additive code/details fields. SelectionRefusal
  now maps to a wire code (selection_invalid, cursor_stale, cursor_invalid,
  reference_outside_target) and a flat string-map details payload via
  to_wire_message(); the legacy array wire (gamecult.networking.v0) has no
  slot for either and never carries them, matching v0 peers ignoring them.
- R-O: the snapshot cursor is keyed. CursorKey is a random 32-byte
  per-process key; Cursor::mint/compute_digest now take it and use
  HMAC-SHA256 instead of plain SHA-256, so a cursor forged without the key,
  or minted under a different process's key, is refused cursor_invalid.
  Cursor::parse stays public and decodes as_of without the key (Huginn's
  dependency); only verify_digest (called from select()) needs it. The
  cursor body itself is now length-prefixed fields, not a delimiter-joined
  string, so a record key carrying any character - including the pre-fix
  C# delimiter U+241F - round-trips.
- R-T: matches_citation (the `cites` path) now checks every one of a
  citer's edges against its declared target before applying the
  citation's own role/key filters, closing F15: a corrupt edge used to
  hide behind an unrelated filter and never get validated.
- The retired Rust mutation harness entries file
  (scripts/mutate-cultnet-selection-rust.entries.ps1) is deleted; tests
  pin the behaviour directly.

Regenerated contracts/cultnet/interop/selection-vectors.rs-written.json
from the shared fixture; it is byte-identical to what was committed, so
R-T's fix does not change any existing fixture vector's outcome. The Rust
reader still evaluates every vector in the committed cs-written.json
(15/15) unchanged - that file predates the parallel C# Hands' R-N/R-S
work and was not touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:26:56 +02:00
Meta Vens
f3667913ec Regenerate cs-written.json with the two new cites-refusal vectors
Ran CULTNET_WRITE_VECTORS=1 dotnet test tests/GameCult.Networking.Tests -c
Release --filter FullyQualifiedName~WriteSelectionVectors on Yggdrasil
against b1932d9 and committed its output verbatim. 17 vectors total (up from
15): the 4 door-refusal vectors now include
refuses_unmatched_cites_target_schema and
refuses_cites_target_by_hash_shaped_alias.

Per the brief, this is the fix-batch-3 report's parity-vector table baseline.
The C# reader (SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyIn
TheReference) now passes all 16 of rs-written.json's vectors, up from 14/16 -
rs-written.json already carried both refusal vectors; only this file and the
door check that makes it agree were missing on the C# side. rs-written.json
itself was not touched (Rust-owned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:24:20 +02:00
Meta Vens
b1932d95ed Add the two pending cites-refusal parity vectors, now that C# refuses them
Item 1 of the fix-batch-3 brief: check whether CultNetSelection.Validate
refuses a cites.target.schemaId that CultNetSchemaAliasMatching cannot
resolve. It already does (CultNetSelection.cs:389-394), landed by an earlier
Hands pass on this branch (the "C# matrix Hands", after the parity table in
the spec was captured) - so no production code changed here.

What was still missing: SelectionParityVectorTests.RefusalCases() had a
comment explaining why refuses_unmatched_cites_target_schema stayed
"Rust-only", dated before that door check landed. The comment was stale and
the vector was never added. Added it, plus
refuses_cites_target_by_hash_shaped_alias (Self's ruling 2026-09-22: "the C#
reference's alias rule is the rule" - C# only resolves a name-form alias, so
a hash-shaped cites.target.schemaId is exactly the same unresolved-target
case and must refuse at the same field). RefusalCases() now takes the
fixture's schemas dictionary to reach schemas["leaf_a"].HashAlias.

Verified on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release
--filter FullyQualifiedName~SelectionParityVectorTests (the existing
cross-runtime reader against the committed rs-written.json still passes, so
this change does not depend on anything the Rust Hands owns). Regenerated
contracts/cultnet/interop/selection-vectors.cs-written.json with
CULTNET_WRITE_VECTORS=1: both new refusal vectors are present and both
refuse at "cites.target.schemaId" as expected before being committed as
vectors (WriteSelectionVectors's own Assert.Throws gate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:22:18 +02:00
Meta Vens
f8ba2d0aca R-R vs R-F discrepancy: CultMesh's typed API refuses empty keys, doesn't answer empty
Discrepancy between spec and Body, reported per Hands' brief ("make the
smallest change that keeps the spec's intent, and report the discrepancy").

R-R says "recordKeys: [] answers empty" for v0 lowering. That holds for the
raw v0 wire protocol server (CultNetDocumentRegistry.CreateRawSnapshotResponse,
CultNetDatabase.CreateShardSnapshotResponse - fixed two commits back). It does
not hold for CultMesh's typed convenience API: R-F, landed earlier in this
cut, made CultMesh.EnsureV0Compatible run CultNetSelectionValidation.
ValidateShape client-side before a request is ever sent, specifically so that
"Keys=[]/[\"\"] is refused rather than silently lowered to 'every key'"
(CultMeshSnapshots.cs:836-841). Once R-R made ResolveDefaultSelection's
Clean(recordKeys) produce Keys=[] instead of Keys=null for an explicit empty
list, every FetchDocumentsAsync(recordKeys: []) call now trips that existing
R-F door and throws, instead of reaching the wire at all.

Kept both rulings as each one's own owner decided them: the raw wire protocol
answers empty (R-R); CultMesh's typed wrapper refuses before sending (R-F).
Rewrote FetchDocumentsAsync_WithExplicitEmptyRecordKeys_AnswersEmpty, added
two commits ago on the wrong assumption that CultMesh's typed API also
answers empty, to FetchDocumentsAsync_WithExplicitEmptyRecordKeys_
IsRefusedClientSide, asserting the actual (and, on inspection, correct) R-F
behaviour instead.

Verified on Yggdrasil: dotnet test tests/GameCult.Mesh.Tests -c Release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:18:00 +02:00
Meta Vens
1afbaed775 R-R fixup: v0's empty-answers-empty must not reach the v1 door
The first attempt at R-R built a CultNetSelection with an empty (non-null)
Keys/Schemas array straight from the lowered v0 filter and handed it to the
shared evaluator. CultNetSelectionValidation.ValidateShape refuses exactly
that shape ("selection.keys is present and empty"), because for the v1 typed
door an empty list is a caller mistake. v0 and v1 read the same emptiness
differently on purpose (v0: answer nothing; v1: refuse), so v0 must decide
its answer before the selection ever reaches Validate.

CultNetDocumentRegistry.CreateRawSnapshotResponse and
CultNetDatabase.CreateShardSnapshotResponse now short-circuit to an empty
document set when the lowered schemas or keys list is present-and-empty,
without constructing a CultNetSelection or calling into the evaluator at all.

Caught by the test added in the previous commit
(CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty),
which failed with CultNetSelectionInvalidException before this fix. Verified
on Yggdrasil: dotnet test tests/GameCult.Networking.Tests -c Release, all
green (217 passed, 2 skipped by design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:15:22 +02:00
Meta Vens
5e33d81e25 R-Q3, R-R: v0 lowering preserves v0's pre-cut answers exactly
CultNetV0SelectionLowering.Lower collapsed a non-null-but-empty filter list to
null ("no filter"). That corrupted two pre-cut v0 behaviours the same
function feeds:

- CultNetDatabase.CreateShardSnapshotResponse and
  CultNetDocumentRegistry.CreateRawSnapshotResponse (b3d9cf7) both test
  `filter?.RecordKeys != null`, not a length, so `recordKeys: []` always
  answered empty. Lowering the empty list to null made it answer every row
  instead. This corrects the ruling from the commit-2 fix batch that lowered
  an empty v0 list to "no filter".
- CultMesh.ResolveDefaultSelection used the same collapse to decide whether an
  explicit empty recordKeys should still fall back to the document type's own
  schema default; under the corrected Lower it must not.

Lower(null) still means "no filter"; Lower(non-null) is always a real
(possibly empty) array. The evaluator already treats a non-null empty Keys
array as "match nothing" (CultNetSelectionEvaluator.MatchesSchemaKeysFields);
only the v0 lowering was erasing that signal before selections reached it.

Also restores the pre-cut requested-key order for
CultNetDocumentRegistry.CreateRawSnapshotResponse: pre-cut, requested record
keys were iterated in the order the caller sent them (a HashSet<string> built
from the array, which enumerates in insertion order); the new shared
evaluator's constant ordinal fell back to schema-and-key sort order instead.
ordinalOf now ranks rows by their position in the requested key list when one
was given.

Rewrote CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys
(renamed ...AnswersEmpty) to assert the corrected behaviour instead of the
ruling it used to pin. Added
CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty,
CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder and
CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty to
NetworkingTests.cs.

Not done in this commit: CultNetDatabase.CreateShardSnapshotResponse (the
shard-replication snapshot path) still orders by the evaluator's default
schema-and-key tiebreak. Its own pre-cut order was raw cache iteration order,
which the new evaluator has no equivalent for and shard replication does not
appear to depend on row order; flagged as a discrepancy rather than changed
without a test that would pin the "right" order. The side-by-side b3d9cf7 vs
HEAD comparison run the spec asks for was not executed in this pass; the
pre-cut behaviour above comes from reading CultNetDatabase.cs and
CultNetDocumentRegistry.cs at b3d9cf7 directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:31 +02:00
Meta Vens
8d7bee9346 Delete the retired hand-written mutation harness from this branch
Operator ruling, 2026-09-22: "Better to have nothing than a harness that
punishes refactoring." Removes scripts/mutate-dotnet.ps1 and every
scripts/*.entries.ps1 mutation-entry file for CultNet selection (networking,
mesh, caching, fix-batch-2). Also drops the two live code comments in
tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs that cited entry ids
(MESH-KEY-Loosening, MESH-DEFAULT-Loosening), rewording them to describe the
rule instead of the retired harness. Mutation testing now uses Stryker.NET on
the cut's diff, run by Soul.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:12 +02:00
Meta Vens
0714492858 CultNet selection cut1 fix batch, second Hands pass: R-D mutation entries and Mesh R-F/R-L coverage
- Repairs two stale mutation entries in
  scripts/mutate-cultnet-selection-caching.entries.ps1
  (CACHE-QJ-ExponentExpansion-Revert, CACHE-QJ-NaNInfinity-Revert): both
  anchored on ExpandScientificNotation/the pre-R-D switch arm, which the
  first fix-batch pass's R-D commit (7903853) deleted outright in favour of
  RenderExactFloat/RenderExactDouble. Replaces the exponent-expansion entry
  with CACHE-RD-ExactExpansion-Revert (reverts to .NET's shortest
  round-trip ToString(), the exact defect R-D fixes) and
  CACHE-RD-FractionSplit-Loosening (an off-by-one digit-split bug that
  still looks "exact"), and re-anchors the NaN/Infinity entry on the
  current RenderExactDouble call.
- Repairs one stale mutation entry in
  scripts/mutate-cultnet-selection-mesh.entries.ps1 (MESH-FOREIGN-Revert):
  anchored on the pre-R-L inline TryReadSchemaVersion call, which R-L's
  commit (c01bc0a) replaced with CultNetDocumentRegistry.PayloadMatchesSchema.
  Re-anchors it and adds MESH-RF-Door-Revert (R-F: EnsureV0Compatible runs
  CultNetSelectionValidation.ValidateShape before its own v0 transport
  refusals), with a new tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs
  proving FetchSnapshotAsync refuses Keys=[]/[""] before ever touching a
  client.
- All entries verified: Caching 11/11 killed, Mesh 7/7 killed (pending final
  confirmation on this run), restores byte-exact.

R-L (docs/cultnet-selection-cut.md): the Python refusal of vN>0 already has
a real regression test (packages/cultnet-py/tests/test_cultnet.py:1958-1978,
unsupported_schema_version) but no mutation entry - mutate-dotnet.ps1 is a
dotnet/NUnit harness only and this repo has no Python mutation harness. R-L's
C#-adjacent piece (TryReadSchemaVersion going internal behind
CultNetDocumentRegistry.PayloadMatchesSchema) is covered by
MESH-FOREIGN-Revert above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:11:21 +02:00
Meta Vens
d94174d597 CultNet selection cut1 fix batch, second Hands pass: mutation entries and S9
- Adds mutation entries for R-A, R-B, R-C, R-E, R-F, R-G, R-H, R-J
  (scripts/mutate-cultnet-selection-networking.entries.ps1), each a revert
  and a loosening where the rule supports one, targeting
  CultNetSelectionEvaluator.cs, CultNetSelection.cs, CultNetDatabaseServer.cs
  and CultNetDatabaseSubscriptionServer.cs.
- Adds S9 (docs/cultnet-selection-cut.md section 10): a new RUDP round-trip
  test proving a hop-bearing v1 subscription reconciles the whole matched set
  when the citer (not the page row itself) changes, plus its own mutation
  entry (NET-S9-HopFastPath-Revert) reverting D6's Watch routing.
- Adds tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs:
  regression coverage for R-B (edge anchor/order), R-C (code-point order in
  both the row tiebreak and the cursor position compare), R-E (alias
  matcher; door refusal), R-F (Select validates unconditionally), R-H
  (digest collision), R-J (numeric any_of canonical rendering) - none of
  these rules had a test that could fail independently of the fix-batch
  regression tests already in place.
- Fixes a real gap R-E named but the first fix-batch pass never wrote:
  CultNetSelection.Validate now refuses a cites.target.schemaId that
  matches no declared schema, at the door, instead of MatchesCitation
  silently treating an unresolved target as "not found" and answering an
  empty page. Resolved through CultNetSchemaAliasMatching, the reference
  rule, against every declared schema - not only the selection's own
  Schemas-filtered reachable set, which a second entry
  (NET-RE-DoorTargetReachableOnly-Loosening) proves: a cites target is a
  separate structural reference, not itself constrained by Schemas.
  Coordinator-assigned item; the shared parity fixture's
  refuses_unmatched_cites_target_schema vector (Rust side, another Hands)
  is not yet in selection-vectors.rs-written.json, so
  SelectionParityVectorTests.SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
  does not yet exercise it - it will once that vector lands, unmodified
  by this commit.
- NetworkingTests.cs gains CultNetDatabaseServer_AnswersV1SnapshotRequestRespectingItsFields
  (R-A's loosening) and DatabaseSubscriptionServer_SubscribesV1AndReconcilesAHopBearingSelectionWhenTheCiterChanges
  (S9 / R-A's subscribe-listener revert).

Discrepancy: R-A's snapshot-request v1 listener registration
(CultNetDatabaseServer's _server.OnCultNet(_snapshotHandlerV1)) has no
mutation entry. CultNetDatabaseServer pairs only with Server (the
LiteNetLib/TCP-secured production construction site), and no existing test
in this suite - before or after this pass - drives Server's real
client/listener dispatch for CultNetDatabaseServer; every test calls
CreateSelectionResponse/CreateSnapshotResponse directly. That revert is
recorded as not yet reached in the entries file, not swapped for an easier
mutant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:11:21 +02:00
Meta Vens
a31993d518 CultNet selection: update mutation entries for the R-E rewrite
The alias-matching call sites changed shape (schema_alias::matches/
matches_any now take (candidate, schema_id, schema_name)), so the entries
that edit them needed their Old/New anchors and Test names updated:
RS-E-Alias-Revert (now targets the ported name-comparison line, not the
deleted hash-stripping compare), RS-E-CitesTarget-Revert, and
RS-E-EmptySchemasNeverMatchesEverything-Revert.

New entries for this cut's rule, per spec:
- RS-E-Alias-RevertToHashStripping: the revert - go back to hash stripping
  (compare schema_id against schema_id's own inferred name, the deleted
  matcher's behaviour).
- RS-E-Alias-CaseInsensitive-Loosening: the loosening - compare
  case-insensitively instead of Ordinal.
- RS-E-Alias-AcceptAnyVersionSuffix-Loosening: a second loosening - accept
  any ".vN" suffix without comparing the name.
- RS-E-InferSchemaName-StripAnyLastDotSuffix-Loosening: a function-of-input
  mutant - infer_schema_name strips any suffix after the last dot, not only
  a ".v<digits>" marker.

Also fixed the invocation: File/Command values are relative to the CultLib
repo root and carry --manifest-path packages/cultnet-rs/Cargo.toml (there is
no repo-root Cargo.toml/workspace), matching -Repo C:\wal and -Target
'packages/cultnet-rs/src/selection.rs,packages/cultnet-rs/src/snapshot_query.rs'.

Verified: M0 green (4 commands, 36+81+7+1 tests), all 40 entries killed by
their own test, every restore byte-exact (git diff clean against the
committed source after the run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:19:12 +02:00
Meta Vens
7a9fabfa2c CultNet selection: port the C# alias rule into cultnet-rs, delete the hash matcher
Self's ruling, 2026-09-22 ("the C# reference's alias rule is the rule"): an
alias is a schema *name* with a trailing .vN suffix, resolved against the
descriptor's declared name - never against a hash id. C#'s production call
sites (CultNetSelection.cs:438, CultNetSelectionEvaluator.cs:88,340) all use
the descriptor overload of CultNetSchemaAliasMatching.Matches, never the bare
id-vs-id overload.

- Row gains schema_name() (defaults to schema_id() for rows with no separate
  name, e.g. raw wire records); RowSet gains schema_name(schema_id) ->
  Option<String>. Both stand in for CultDocumentDescriptor.SchemaName, which
  this crate has no type for (D7).
- schema_alias::matches/matches_any now take (candidate, schema_id,
  schema_name) and resolve a candidate's inferred name against the declared
  name, exactly as the C# descriptor overload does - not against schema_id's
  own inferred name, which is what the deleted hash-stripping matcher did.
- Every call site (reachable_schemas, the cites.target door check,
  matches_schema_keys_fields's D6 fast path, matches_citation) now resolves
  and passes a schema's name instead of comparing two schema-id strings.
- Deleted: the hash-stripping schema_alias::matches(candidate, schema_id)
  two-string overload. A hash-shaped alias ("<hash>.v1") is not a wire form
  either runtime's production rule ever resolves.

Behaviour verified against a standalone run of the exact C# algorithm
(CultNetSchemaAliasMatching, CultNetDatabase.cs:47-97) covering exact-id
match, bare-name match, .vN alias resolution, MatchesAny's empty-matches-all
convention, ordinal case sensitivity, ".v" with no trailing digits, and
multiple ".v" markers (last one wins) - every case is now a Rust unit test
in schema_alias::tests.

Test fixture (tests/selection.rs) updated: the fixture's hashAlias entries
are now a shared negative check (neither runtime resolves them), and the
nameAlias entries now resolve in Rust too, fixing the confirmed cross-runtime
defect (schemas_by_name_alias). Regenerated
contracts/cultnet/interop/selection-vectors.rs-written.json (15 -> 16
vectors): added refuses_cites_target_by_hash_shaped_alias, renamed
hop_by_role_cites_hash_alias to hop_by_role_cites_name_alias.

cultnet-rs: cargo test 223/223. C# reader (rs-written.json): 14/16 - the two
failures are both the pending unmatched-cites.target door refusal
(CultNetSelection.cs:361-391 has no such check yet), not a Rust defect; the
other Hands' C# door fix covers both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:51:35 +02:00
Meta Vens
defb9a110f CultNet selection cut1 Rust: build from the shared parity fixture
Companion to the C# side's shared-fixture commit. Replaces the
invented fake sha256-shaped LEAF_A/LEAF_B/CITER/LEAF_A_ALIAS consts
with lazily-loaded values read from
contracts/cultnet/interop/selection-vectors.fixture.json (a
std::sync::OnceLock-backed loader; FixtureRowSet's declared_indexes/
declared_roles/is_numeric/target_leaves become runtime string
comparisons instead of const match patterns). NARROW_CITER stays a
local, unshared id - it never appears in a cross-runtime vector.

base_rows() keeps S2-S23's original 7-row set (now sourced from the
fixture's first 7 rows, unchanged in value or order, so every test
that enumerates it - orders_by_ordinal_then_identity_and_reverses,
pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor,
matched_is_the_total_count_not_the_page_count, ... - still holds
unchanged). A new all_fixture_rows() carries all ten rows (the
fix-batch float-tie and astral/BMP rows too) for the two parity-vector
functions only.

write_selection_vectors_for_the_reference gains float_tie_exact_ge,
astral_key_code_point_order, and the fixture's hash alias
(leaf_a_hash_alias(), a synthetic ".v1" this crate's own schema_alias
resolves) and name alias (leaf_a_name_alias(), "leaf_a.v9" - the form
only CultNetSchemaAliasMatching's descriptor overload resolves)
probes, mirroring the C# side. selection_vectors_written_by_the_
reference_decode_and_evaluate_identically_in_rust no longer panics on
the first disagreement (evaluate_vector now returns a Result instead
of .expect()'ing); it collects every vector's mismatch and fails once
with the full list, matching the C# reader's same change.

Regenerates selection-vectors.rs-written.json from the shared fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:33:55 +02:00
Meta Vens
3317dddf46 CultNet selection cut1: the shared C#/Rust parity fixture
Reconciles the two Hands' independently-regenerated parity vectors
(Soul's whole-cut pass, F3): the C# fixture used schema *names*
("leaf_a") as literal ids, the Rust fixture used invented fake
sha256-shaped ids - every vector carrying an id failed when read by
the other runtime.

Adds contracts/cultnet/interop/selection-vectors.fixture.json: the one
row set both writers now build from, keyed by each schema's real
SHA-256 content-hash id exactly as CultDocumentRegistry computes it
(dumped via a temporary DumpFixtureSchemaIds run). Carries the rows
either runtime needs - astral/BMP-private-use keys, a float tie,
cites/cited, a cites target reached by alias - plus two DIFFERENT
alias forms, because the two runtimes' alias matchers key off
different descriptor attributes and no single candidate resolves
through both (see the fixture's "//aliases" note and the report).

Rewrites SelectionParityVectorTests.cs to load the fixture instead of
hand-building rows (BuildFixture/BuildExtendedFixture deleted), assert
the fixture's declared schema ids still match what the registry
computes (fails loudly on drift instead of silently comparing wrong
ids), and use the real schema id (not SchemaName) as a row's
cross-runtime identity. Also fixes a real bug this file carried since
before the shared fixture: both the writer and the reader compared
`evaluation.Rows.Count` (the page size) against "matched" instead of
`evaluation.TotalMatched` (the total, per R-G) - selections with a
Limit were writing/checking the wrong number. The reader now collects
every vector's mismatch instead of stopping at the first one, so a
real parity defect is reported in full.

Regenerates selection-vectors.cs-written.json from the shared fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:33:29 +02:00
Meta Vens
a088435f9f CultNet selection cut1 Rust fix batch: mutation entries for R-B/D/E/F/G/H/I
Adds a revert (and loosening, where the rule has one) per new rule to
scripts/mutate-cultnet-selection-rust.entries.ps1, and repairs three things
the entries file's own first run against this fix batch found:

- The RS-QJ-ExponentExpansion-Revert entry targeted
  expand_scientific_notation, which R-M deleted; replaced by entries
  against R-D's real replacement (the mantissa/exponent bignum).
- RS-D-ExponentBranch-Revert's first draft ("New = if false") hung: it
  routes every value, including f64::MAX's exponent of ~971, into the
  fraction branch, whose "k = (-exponent) as usize" wraps a positive
  exponent's negation into a huge usize and spins effectively forever - the
  run had to kill that process by PID. "New = if true" is the safe
  direction (a negative exponent's "for _ in 0..exponent" is just an empty
  range, not a cast) and is pinned against a negative-exponent test
  instead.
- RS-V0Lowering-Revert (pre-existing, not from this batch) unexpectedly
  survived the first run: matches_schema_keys_fields's move to
  schema_alias::matches_any (R-E) picked up that helper's own "empty
  candidates matches everything" shortcut, masking the v0-lowering
  mutation this entry exists to catch. Fixed in the prior commit
  (matches_schema_keys_fields no longer uses matches_any's shortcut); a new
  entry, RS-E-EmptySchemasNeverMatchesEverything-Revert, pins the fix
  directly rather than only through v0's own lowering.

All commands are narrowed with "-- --skip
selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust":
that vector-file test is legitimately pending the parallel C# Hands's own
regenerated cs-written.json (this suite does not touch that file), the same
carve-out this file already gave the pre-existing
reactive_document_coalesces_direct_same_schema_alias_member_writes flake.

Full run against this batch's final state: 36/36 entries killed, M0 green
on both commands, no survivors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:11:31 +02:00
Meta Vens
4f81c255a3 CultNet selection cut1 Rust fix batch: R-M deletions
- contracts.rs: replaces the ~335-line hand-written require_*/encode_*
  codec for SelectionDocumentRecord/RawDocumentHeader/Edge/RecordRef with
  two generic helpers (require_typed/encode_typed) that go through
  rmp_serde::to_vec_named/from_slice and rmpv::Value's own Serialize -
  these four types already derive Serialize/Deserialize with the wire's
  camelCase field names. rmpv::ext::to_value/from_value was tried first and
  rejected: it serializes a struct positionally (one array slot per field
  in declaration order), which breaks the moment a skip_serializing_if
  field is actually skipped and is not the named-map encoding this wire
  always uses. Proven both ways by
  tests/selection.rs's snapshot_response_raw_v1_round_trips_* tests, which
  round-trip real headers, documents-with-payload, and edges with and
  without a payload through the real cultnet.snapshot_response_raw.v1
  message envelope.
- snapshot_query.rs: deletes reject_duplicates and its two call sites -
  dead, because lower_v0_list already dedups (via its own HashSet) before
  reject_duplicates ever runs, so its duplicate check can never fire.

(RawDocumentHeader::from_document, the Eq derives on the selection wire
types, and two dead branches the mutation harness's own first run proved -
exact_decimal_from_mantissa's redundant "if mantissa == 0" guard and
big_mul_small's unreachable multi-digit carry loop - are selection.rs
changes already covered by the prior commit in this batch.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:11:31 +02:00
Meta Vens
8b114ed353 CultNet selection cut1 Rust fix batch: R-B/C/D/E/F/G/H/I
The Rust half of the Cut 1 fix batch (docs/cultnet-selection-cut.md, "Self's
rulings for the Cut 1 fix batch, 2026-09-22"), against packages/cultnet-rs.

- R-B: hop edges now follow the hop's direction - cites keys an edge off its
  From (the citer), cited off its To (the citee), each in page-row order
  then (from, role, to) code-point order. Before this, both hop kinds keyed
  off From, so a cited selection's edges were silently empty unless the
  citer also happened to be on the page (Soul's F2). build_incoming_index
  iterates the caller's own row slice, never a HashMap, for its own order.
- R-C: code-point order was already Rust's default str::cmp (UTF-8 byte
  order); no change needed there.
- R-D: canonical_number::render_f32/render_f64 render the exact IEEE-754
  decimal expansion from mantissa and exponent, via a small fixed-point
  bignum (~60 lines), replacing the previous shortest-round-trip-form path
  that rounded ties differently from .NET. expand_scientific_notation and
  its test are gone (R-M); the Display probe test is kept per the map.
- R-E: schema_alias (ported from CultNetSchemaAliasMatching's string
  overload) is the one schema-identity rule for `schemas` and
  `cites.target.schemaId`; an unmatched cites target is refused at the
  door. Found and fixed along the way: matches_schema_keys_fields must not
  use schema_alias::matches_any's own "empty candidates matches everything"
  shortcut (correct for the door/reachability call sites it mirrors from
  C#) - v0's snapshot server calls matches() directly, without validate(),
  so an empty-but-present schemas list must still match nothing there too.
- R-F: select() validates first and returns the typed refusal; matches()'s
  hop guard is a real assert!, not a debug_assert! a release build would
  compile out.
- R-G/P-1: Evaluation and SelectionPage both carry matched, the total match
  count, not the page count.
- R-H: Cursor::compute_digest is length-prefixed for every string and list,
  closing the collision Soul found (["a|b"] and ["a","b"] used to digest
  the same).
- R-I: select_page projects a selection into the wire's SelectionPage -
  headers or documents under Selection::projection, matched, and edges; a
  header projection carries no payload, in edges too (S20).

Test fixture: real SHA-256-shaped schema ids (sha256:<hex>.v1) replace the
old fixture's schema names doubling as ids, which had silently masked the
R-E gap (vectors passed only because "leaf_a" happened to already be both
the alias and the id).

Two documented discrepancies against the map's own worked numbers, both
verified independently outside this crate (Rust bit decomposition plus a
Node/BigInt cross-check): an f32 of 3e20 is 300000006012263202816, not the
map's 300000002010536247296; 1e-7 as f64 is its own 79-digit exact
expansion, not "0.0000001" (a stale shortest-form assertion this fix batch
did not originally touch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:11:31 +02:00
Meta Vens
3d32c67821 CultNet selection cut1 fix batch: regenerate the C#-written parity vectors
Regenerated contracts/cultnet/interop/selection-vectors.cs-written.json
under R-B/R-C/R-D/R-E/R-I.

SelectionParityVectorTests.cs: the six original cases' fixture
(BuildFixture) is left untouched, because SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
reads the already-committed selection-vectors.rs-written.json against
it, and a new row reachable by an unfiltered or loosely-filtered case
(descending_with_limit, gt_numeric_boundary) would silently change that
case's expected page. The fix-batch rows and cases live in a new
BuildExtendedFixture/Cases set, additive over BuildFixture, used only
by WriteSelectionVectors:
- float_tie_exact_ge: a row at 2233759.25f, on the round-to-even tie
  R-D's exact BigInteger rendering fixes (its canonical decimal is
  "2233759.25" regardless of which shortest form a runtime's ToString
  would have picked).
- cites_target_by_alias: cites.target.schemaId given as "leaf_a"
  (ParityLeafA's SchemaName) against a-eq's real content-hash SchemaId,
  exercising R-E's alias-matched cites (the pre-fix code required an
  exact SchemaId match and this always missed).
- astral_key_code_point_order: two rows sharing one ordinal, keyed by
  a BMP private-use character (U+E000) and an astral character
  (U+1F602, surrogate pair 😂), pinning R-C's code-point
  tiebreak order.
- header_projection/document_projection: the same hop-bearing match set
  under each projection.

Known-expected failure: SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
fails against the currently-committed selection-vectors.rs-written.json
(incoming_exists_true's edge count) because that file predates R-B and
was not touched here - the parallel Rust Hands owns it. Re-run once it
regenerates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:40:18 +02:00
Meta Vens
c01bc0a129 CultNet selection cut1 fix batch: R-L (contract, Python, mutate-dotnet.ps1)
- cultnet.selection.schema.json: schemas/keys gain minItems: 1 (present
  and empty is refused at the door, never "every schema/key" - omit the
  field instead); limit documents that it is clamped 1..=200 by the
  answering server rather than refused outside that range.
- cultnet_py/interop_peer.py: handle_server_message refused only an
  exact ".v1" suffix, so a v2+ selection message fell through to a
  silent empty-list answer instead of a loud refusal. is_unsupported_cultnet_version
  matches any cultnet.*.vN with N > 0 instead. test_cultnet.py's R-2
  test now covers v2 and v10, not only v1.
- mutate-dotnet.ps1: an entry's File (from -Entries, often written with
  "/") is looked up against the $targets dictionary keyed by -Target's
  own spelling; a "\\" vs "/" mismatch didn't throw at the lookup -
  $targets[$file] silently returned $null - so the crash surfaced one
  call later as a null-argument error inside a .NET path API instead of
  as "unknown target". Both -Target and every File read from an entry
  now normalise through Normalize-TargetPath before indexing $targets.

Discrepancy: the ruling names "both R-2 sites" for the Python fix; only
one site with this exact narrow-match bug exists in packages/cultnet-py
and packages/cultmesh-py (cultmesh_py/server.py's handle_message already
refuses every unrecognised schemaVersion, v1+ included, through its own
default branch). Fixed the one that exists; did not invent a second.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:53 +02:00
Meta Vens
915262fee2 CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix
- R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20
  mutants - dropping `wantDocument &&` in ToEdge (a header-projection
  edge would carry document payload bytes anyway) and always emitting
  Edges instead of `selection.HasHop ? ... : null` (a hop-less
  selection's page would carry an empty array instead of no Edges
  member) - plus a matched-vs-page-length test for R-G. Fixing
  ToEdge's mutant coverage surfaced a real bug the new tests caught:
  it serialized an edge payload through CultDocumentMessagePackSerialization,
  which requires the payload's type to be a registered CultDocument
  and throws for a plain declared value type (float, in the fixture);
  it now serializes directly through MessagePackSerializer.
- R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's
  $ matches immediately before a single trailing newline even without
  RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z
  and would have accepted "5\n" as canonical without this. Caching's
  TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits
  is updated for 1e-7's actual exact decimal expansion (the R-D commit's
  own effect): the old assertion pinned "0.0000001", the shortest
  round-trip form a pre-R-D render would have produced.
- R-K: every serverThread.Join() in NetworkingTests.cs that sat outside
  a try/finally now sits inside one, so a failed assertion cannot leave
  the poll thread running past the point where `using` disposes the
  socket it polls (an ObjectDisposedException on that raw background
  Thread was a host crash, not a test failure). The bodyDemand.HasConsumers
  assertions wait on the condition instead of assuming
  SubscribeAsync's completion and PublishDemand happened in an order
  a network round trip does not guarantee.
- Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias
  built a v0-shaped CultNetDatabaseSubscribeMessage to invoke
  CreateMatchedRecord by reflection; that method now takes the shared
  SubscriptionRequest (R-A), so the test builds one through the same
  reflection path instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00
Meta Vens
790385364c CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.

Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
  reports against (the citer under cites, the cited row under cited),
  not filtered by "From is on the page" regardless of direction; order
  is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
  string.CompareOrdinal in the row tiebreak, cursor position and edge
  order, so an astral key sorts the same way here as it does under
  Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
  its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
  never .NET's shortest round-trip form, which rounded a tie the
  opposite way from Rust at some values. The now-dead ExpandScientificNotation
  is deleted.
- R-E: MatchesCitation compares a cites target through
  CultNetSchemaAliasMatching instead of an exact SchemaId string
  compare; ToEdge and ToRawRecord now emit the same wire schema id
  (WireSchemaId) for the same row, where ToEdge previously used the
  descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
  - the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
  is gone, so no public evaluation path can skip the door. Mesh's
  EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
  (the declaration-independent half of the door) before its own v0
  transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
  SelectPage's Matched field now reports it instead of the page's row
  count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
  (v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
  which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
  projects every matched row, instead of looping SelectPage per 200-row
  page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
  (AppendString/AppendList) instead of delimiter-joined, closing the
  collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
  rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
  fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
  instead, the one binding-alias reconciler. The duplicated v0/shard
  lower-and-page loop is gone with SelectAll above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
Meta Vens
00f4c02dd7 CultNet selection cut1, commit 4: the parity vectors, and the ruling this branch landed under
Two things landed on this branch while commit 3 was in flight, both addressed
here rather than left for the vectors to paper over:

**Self's ruling, 2026-09-22 (commit 2 fix batch, 568e8e3/021e3f1): empty
lists, blank entries, and v0's own cleaning.** Rust already refused an empty
schemas/keys list (inherited from before the ruling); it now also matches
the reference's three further decisions exactly, not just in spirit:

- The door's refusal field is the plain list name ("schemas"/"keys"), not an
  indexed "schemas[i]" - selection::validate's SelectionInvalid.field now
  matches CultNetSelectionInvalidException("schemas", schema, ...) literally,
  because the parity vectors compare this string across runtimes.
- snapshot_query.rs's lower_v0_list now deduplicates a mixed list the same
  way CultNetV0SelectionLowering.Lower does (Distinct(StringComparer.Ordinal)
  - order-preserving, first occurrence wins), not just blank-filters it.
  A new test (serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter,
  extended) pins the dedup case directly.
- Two mutation entries (RS-EmptySchemas-Revert, RS-EmptyKeys-Revert) added
  for the door's original empty-list refusal, which had tests but no
  mutation coverage before this commit.

**S12: the two-way parity vectors**, contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json,
eight vectors each: the six evaluated selections from commit 3 (any_of+ge
conjunction, descending+limit, gt boundary, cited exists true/false, three
rows sharing one index value) plus two door-refusal vectors added for this
ruling - `refuses_empty_schemas_list` (schemas: []) and `refuses_blank_key_entry`
(keys: ["a-eq", "   "]) - which decode their selection bytes and assert
`validate`/`Validate` refuses at exactly the named field, never evaluating.
The blank-key vector could not be generated before this commit: the door
rule it exercises did not exist in the reference until 568e8e3 landed.
`cites`-based hop vectors are deliberately absent (documented in both test
files): MatchesCitation compares Citation.Target.SchemaId against the
reference's real SHA-256 schema id exactly, with no SchemaName fallback,
and this fixture pair has no way to agree on that hash across runtimes;
`cited` (role/existence, matched by record key alone) is the hop direction
both sides can cross-check, so incoming_exists_true/incoming_negation carry
that coverage instead. Both reader tests pass:
SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference (C#)
and selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust
(Rust), each judging the other runtime's file.

**The remaining mutation matrix.** Of the coordinator's named set (S1, S2,
S6, S9-S15, S17, S20, S23): S1 was already covered (RS-EmptySchemas/Keys,
RS-BlankSchema/Key); S2 (any_of matches membership, RS-S2-Revert) and S6
(cites.role filters the hop, RS-S6-Revert) are newly covered - S6 needed a
new fixture (cites_role_excludes_a_second_reference_at_the_same_target)
because base_rows()'s citer-1 only ever carries one reference, so a mutant
dropping the role filter had nothing else to wrongly match and survived
against the original hops_one_edge_by_declared_role test; that finding is
recorded in-line as a comment, not silently swapped for an easier mutant.
S9-S15 (subscription reconcile, v0-lowering-of-subscribe) do not apply:
D7 - no Rust caller subscribes in this cut, so there is no subscription
client to mutate. S17 (inherited alias reaches every leaf) does not apply
either: Rust's toy Row/RowSet has no inheritance to begin with - both
leaves in the fixture declare "mass" independently, which is the Rust
spelling of the same guarantee, already exercised throughout the suite
without a dedicated mutant (there is no shared base-type discovery path to
revert). S20 (edges only for a hopping selection) was investigated and
found structurally vacuous in Rust as built: `edges` is only ever populated
inside the cites/cited branches, so a mutant on the has_hop() gate cannot
produce a different observable result for a non-hopping selection - no
entry added; a real gap would need the evaluator's structure to change
first. S23 (matches every row sharing an index value, never a cache's
unique-index winner) has no revertable bug surface: Rust's Row/RowSet
never had a unique-index map to begin with (D7), so this is pinned by
construction, not by a mutation-testable rule; matches_every_row_sharing_an_index_value
still exists as a positive test naming it.

**A pre-existing, unrelated flaky test, found and worked around, not
fixed.** tests/cultnet.rs's reactive_document_coalesces_direct_same_schema_alias_member_writes
fails consistently when the mutation harness runs the full tests/cultnet.rs
binary (both default-parallel and RUST_TEST_THREADS=1), but passes 5/5 in
isolation and passes in a plain `cargo test -p cultnet-rs` run - state
pollution from another test in that binary under the harness's specific
invocation path, not a race this campaign's code participates in (nothing
here touches reactive_document machinery). RS-V0Lowering-Revert's Command
is narrowed to `cargo test --test cultnet serve_read_only_raw_snapshot`
(filtering both M0 and the entry's own run to this campaign's test) so the
suite does not depend on that unrelated test's health; the flake itself is
reported here as a finding, not patched.

Tests: cultnet-rs 174 passed, 0 failed (default parallel run).
GameCult.Networking.Tests 196 passed, 1 skipped (WriteSelectionVectors,
env-gated), 0 failed.

Mutation harness: 24 entries (was 22 in commit 3; +RS-S2-Revert, +RS-S6-Revert;
RS-BlankSchema/BlankKey and RS-V0Lowering-Revert's anchors and field-name
assertions updated to match the landed fix batch's exact spelling), all
KILLED, M0 green.

Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty.

Structural delta, this commit only: 6 files changed, +108/-23 (selection.rs
+15/-9 field-name fix; snapshot_query.rs +25/-14 dedup; tests/cultnet.rs +21
dedup test; tests/selection.rs +35/-2 S6 fixture and field-name fixes;
scripts/mutate-cultnet-selection-rust.entries.ps1 +33/-4 two new entries
plus three corrected anchors/commands; SelectionParityVectorTests.cs +2/-1
field name), plus the two committed vector JSON files (~90 lines each, new).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:55:52 +02:00
Meta Vens
021e3f1bba CultNet selection cut1, commit 2 fix batch: R-2, Python refuses v1 selection messages
R-2 was owed by Cut 1 and not yet done (docs/cultnet-selection-cut.md, section
9, Self's rulings 2026-09-22): a v0-only Python peer must refuse a
cultnet.selection.v1 carrier message loudly instead of silently answering
with an empty list.

- packages/cultnet-py/src/cultnet_py/interop_peer.py:
  handle_server_message's fallback now recognises any cultnet.*.v1 schema
  version and replies cultnet.error.v0 { code: "unsupported_schema_version",
  details: { schemaVersion } } via the new unsupported_schema_version_error
  helper, matching cultmesh_py's existing error shape. One test
  (test_python_interop_peer_refuses_v1_selection_messages) proves a
  snapshot_request.v1 and a database_subscribe.v1 message both get the
  refusal, not an empty list.
- packages/cultmesh-py/src/cultmesh_py/server.py's delegating path
  (_handle_connection_message -> handle_message) already refused every
  unsupported schema version generically, v1 included - no code change
  needed there. test_cultmesh_local_server_returns_errors_for_bad_requests
  gains an explicit assertion for a snapshot_request.v1 message through that
  same path, so the coverage is named rather than only implied by the
  generic case.

This is the one exception to "do not touch packages/" in this fix batch: only
these two Python packages' interop/server files and their tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:13:16 +02:00
Meta Vens
568e8e356f CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.

- CultNetSelection.Validate now refuses a blank/whitespace entry inside
  schemas or keys, not just an empty array. CultNetSelectionEvaluator no
  longer treats [] as "no filter" (the door already refuses it before
  evaluation). CultNetV0SelectionLowering is the one place that lowers an
  empty or all-blank v0 list to null; CreateRawSnapshotResponse and
  CreateShardSnapshotResponse both route through it, and CultMesh's own
  Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
  schema/key matching: it builds a CultNetSelection and answers through
  CultNetDocumentRegistry.SelectPage (the same evaluator path as the
  non-shard snapshot), with the shard's own membership check as a row
  filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
  recordKeys, not the raw array length. OverlaySelection stops deciding what
  a missing override means: any explicit schemaIds/recordKeys override
  replaces the default selection wholesale; no override keeps the default
  untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
  EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
  descending, and any non-default projection are refused typed
  (CultNetSelectionInvalidException), never silently dropped from the v0
  wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
  read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
  in one order - exact schema, then alias, then a foreign schema id decoded
  from the payload - instead of one OR'd condition where a case-insensitive
  or structurally-lucky candidate could win first. The foreign-id decode
  reads the payload's embedded schema version with the registry's shared
  CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
  the caller's own descriptor directly, rather than resolving to "whichever
  registered type owns this schema string" - that resolution is ambiguous
  when two document types alias the same schema id (MeshNoteDocument and
  MeshNoteAliasDocument in the existing fixtures), which is what broke on
  the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
  caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
  DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
  (RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
  favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
  comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
  throw in CreateProjectedSnapshot, are deleted - they can no longer fire
  now that a projection's dictionary key is always the record's own key.
  A new test flips authorizeRecord per key while the whole request stays
  authorized (so DemandActive never toggles) and proves both halves of the
  remaining diff: the removal of a now-unauthorized key and the add loop
  picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
  joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
  longer throws the whole run away under PowerShell 5.1's
  $ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
  a terminating NativeCommandError); Run-Tests catches it and the entry loop
  records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
  UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
  snapshot paths agree, including shard-membership exclusion of a document
  outside the shard's key prefix and a binding schema-id override matching
  on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
  entry (its anchor was the private loop this batch deleted, not refactored)
  is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
  header miscount ("two of three" loosening entries when there are only
  two) is corrected and both previously-surviving loosening mutants
  (MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
  scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
  Reconcile's remaining two mutants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:13:15 +02:00
Meta Vens
b090ca97f9 CultNet selection cut1, commit 3: the Rust runtime
docs/cultnet-selection-cut.md section 6/7: packages/cultnet-rs/src/selection.rs
(new) - the Rust spelling of the vocabulary at wire parity with the corrected
C# reference (e5348d3), matching D7: no cultcache-rs knowledge, the row owner
supplies declared values and edges through two traits.

- Wire types: Selection, FieldPredicate, Citation, Incoming, RecordRef, Edge,
  RawDocumentHeader, SelectionDocumentRecord (the v1 full-shape record, distinct
  from v0's CultNetRawDocumentRecord, which lacks schemaName/schemaVersion/
  schemaContentHash - a pre-existing v0 gap this cut does not touch).
  SelectionOperator mirrors CultNetSelectionOperator for evaluator ergonomics;
  FieldPredicate.op stays a raw wire string, never a serde-tagged union.
- canonical_number module (Q-J, section 2 "Numbers"): is_canonical (a
  hand-rolled digit-grammar parser, no regex dependency added),
  canonicalize_decimal_digits, expand_scientific_notation, compare (sign, then
  integer length, then digits, then padded fraction - no f64 anywhere), and
  render_i64/render_u64/render_f64/render_f32 for a Row implementer's own
  rendering. probe_f64_display_never_uses_exponent_notation empirically
  verifies the map's claim about Rust's f64 Display (confirmed true) rather
  than trusting it.
- Row/RowSet traits (D7): schema_id, record_key, ordinal, values, number
  (canonical string, never f64), references for Row; all_schema_ids,
  declared_indexes, declared_roles, is_numeric, target_leaves for RowSet.
  A blanket `impl<T: Row> Row for &T` lets sorted/filtered slices of
  references satisfy generic bounds without an extra copy.
- validate (S1 door refusals), matches (D6 single-row fast path, hop-bearing
  selections assert via debug_assert since Rust has no exception type to
  mirror C#'s InvalidOperationException), select (order/hop/cursor/paging,
  D9's reference_outside_target refusal, S23's per-row match - never through
  a unique-index map, because there is none here to begin with).
- Cursor: mint/parse/digest is this runtime's own internal algorithm (a
  cursor is opaque and never crosses runtimes - each server answers its own
  pages), not a byte-for-byte port of the reference's; what must match is the
  *rule* (asOf + last position + a digest bound to the selection), which it
  does, pinned by RS-CursorStale-Revert/Loosening and RS-CursorDigest-Revert.
- contracts.rs: three v1 CultNetMessage variants (SnapshotRequestV1,
  DatabaseSubscribeV1, SnapshotResponseRawV1) matching the C# messages'
  field-for-field shape. SnapshotResponseRawV1 carries binary payloads
  (documents[].payload, edges[].payload), so it is routed through the
  existing raw JSON-bypass path (parse_raw_cultnet_schema_message/
  encode_raw_cultnet_schema_message) alongside v0's DocumentPutRaw/
  SnapshotResponseRaw, with its own require_*/encode_* helpers for
  SelectionDocumentRecord, RawDocumentHeader, Edge and RecordRef.
  SnapshotRequestV1/DatabaseSubscribeV1 carry no binary and use the
  ordinary serde_json-intermediate path. No message-layer selection
  semantics validation here, matching the reference
  (CultNetSchemaMessageSerialization.cs only deserializes; a server calls
  selection::validate against its own RowSet before evaluating).
- snapshot_query.rs (D3/D4): serve_read_only_raw_snapshot's two hand-rolled
  allowlist filters collapse into one call to selection::matches over a
  lowered v0 Selection, through a minimal RawSnapshotRow wrapper whose
  ordinal/values/number/references are unreachable (the lowered selection
  never sets fields/cites/cited, so matches_schema_keys_fields never calls
  them) - documented, not hidden.

Ruling (Self, 2026-09-22, docs/cultnet-selection-cut.md commit 2 fix batch,
mirrored here per the coordinator's mid-task instruction rather than C#'s
pre-fix spelling as it stood at e5348d3):
- validate refuses an empty-but-present schemas/keys list (already had this)
  and now also refuses any individual entry that is empty or whitespace
  (fields[i]-style names: schemas[i]/keys[i]).
- The evaluator never sees []; matches_schema_keys_fields does not special-case
  an empty list as "no filter" (it never did here - that C# fallback,
  CultNetSelectionEvaluator.cs:84 pre-fix, was never mirrored into Rust).
- snapshot_query.rs's v0 lowering gets its own cleaning, owned by the
  lowering and not the evaluator: lower_v0_list turns an empty or
  all-blank v0 schemaIds/recordKeys list into None before it ever reaches
  validate or select.

Tests: cultnet-rs 173 passed, 0 failed (was 111 baseline #[test]s pre-cut;
+62 across lib unit tests, tests/selection.rs and tests/cultnet.rs).
cultcache-rs unmodified, 64 passed, 0 failed (untouched by this commit -
confirmed unaffected). GameCult.Networking.Tests (the new
SelectionParityVectorTests.cs file, compiled but not yet fully exercised -
see below) builds clean.

Mutation harness (tools/eureka-mutations.ps1, scripts/mutate-cultnet-
selection-rust.entries.ps1): 22 entries against src/selection.rs and
src/snapshot_query.rs, all KILLED, M0 green. Covers: S1's empty-list and
new blank-entry refusals (schemas and keys, revert x4), order/descending
(revert), cursor staleness (revert + loosening) and digest (revert), S7's
incoming negation (revert + loosening), S16's le/ge boundaries (revert x2),
S18's reference_outside_target (revert), Q-J's door regex and -0 exclusion
(revert x2), Q-J's comparator integer-length and sign (revert x2), Q-J's
integer-through-float64 renderer, decimal trailing-zero stripping and
exponent expansion (revert x3, the last two pinned via direct
canonical_number unit tests since render_f64 itself cannot reach exponent
notation given Rust's Display contract - verified, not assumed), and the
new v0-lowering rule (revert).

Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src`
empty.

Not included here, landing as commit 4 once ready: the committed parity
vector files (contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json).
Both directions already work for six evaluated selections and the []
door-refusal case; the blank-key-entry refusal vector cannot be generated
from the C# side yet because the parallel C# fix batch implementing that
door check (docs/cultnet-selection-cut.md, "Self's rulings...for the commit
2 fix batch") has not landed on this branch as of this commit. Rust's own
tests (validation_refuses_a_blank_entry_in_schemas_or_keys,
serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter)
already pin both new rules independently of C#.

Structural delta: packages/cultnet-rs/src/selection.rs +1407 (new),
contracts.rs +616/-21, snapshot_query.rs +76/-21 (net +55, collapsing the
fifth selector engine into one call), lib.rs +2, tests/cultnet.rs +67 (new
v0-lowering test), tests/selection.rs +900 (new, evaluator tests + vector
machinery), scripts/mutate-cultnet-selection-rust.entries.ps1 +233 (new),
tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +296 (new).
Against section 14's ~430-line estimate for selection.rs+contracts.rs: the
two together land at ~2000 lines added, well over estimate - the map's own
§14 correction already flagged commits 0/1 running roughly 2x estimate for
the same reason (the hop's incoming index, the cursor, and per-field door
messages cost more than the prose assumed), and the v1 raw-message handling
(SelectionDocumentRecord/RawDocumentHeader/Edge require_*/encode_* helpers,
~400 lines) was not separately budgeted at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:31:19 +02:00
Meta Vens
e5348d3d57 CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers)
Corrects commit 1's Number field: docs/cultnet-selection-cut.md section 2
"Numbers" (added 2026-09-22, superseding the map's own stale float64 text)
specifies canonical decimal strings per the operator's Q-J ruling, and
commits 0/1 shipped double instead - the Rust Hands caught the conflict
before writing a line and stopped; this is the correction the map's
Progress block calls for before Rust starts.

- CultNetFieldPredicate.Number: double? -> string?. New CultNetCanonicalNumber
  (CultNetSelection.cs) is the wire's canonical grammar and comparator:
  IsCanonical validates ^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ and excludes "-0"
  (the regex alone matches it); Compare orders by sign, then the integer
  part's length, then its digits, then the fraction padded on the right to
  equal length - a pure string function, no double anywhere on the path.
  ValidateField refuses a non-canonical number at the door
  (selection_invalid) rather than normalising it.
- CultNetSelectionEvaluator.CompareNumber now takes two canonical strings and
  switches on CultNetCanonicalNumber.Compare's sign instead of comparing
  doubles directly.
- CultCache.cs: CultDocumentDescriptor.TryGetIndexNumber and
  PersistedMember.NumberOf render a member's exact canonical decimal instead
  of a double. An integer renders exact (no 2^53 limit); decimal strips
  trailing fractional zeros; float/double use .NET's shortest round-trip
  ToString(), rewritten out of exponent notation to positional digits
  (ExpandScientificNotation) before the shared CanonicalizeDecimalDigits
  strips zeros and collapses a negative-zero result to "0"; NaN and infinity
  render null (TryGetIndexNumber returns false, matching "matches no
  comparison").
- contracts/cultnet/cultnet.selection.schema.json: "number" is now a string
  with the canonical pattern (a defence-in-depth (?!-0$) lookahead beyond
  what the door enforces in code), not a JSON number.
- Tests: CultNetCanonicalNumberTests (new) pins IsCanonical/Compare directly,
  including the door's five named non-canonical spellings, magnitude vs.
  lexicographic ordering, sign, integer-part length, and a long past 2^53
  against its float64 rounding. CultNetSelectionEvaluatorTests gains
  ValidationRefusesNonCanonicalNumberSpellings (+1, 1.0, 01, 1e3, -0) and its
  existing Number literals become canonical strings.
  CultDocumentSelectionSurfaceTests gains a dedicated FixtureLeafNumbers
  fixture and four tests: the 2^53+1 long rendering exactly, a decimal's
  trailing zeros stripped, a double's exponent notation rewritten to
  positional digits (1e21, 1e-7), and NaN/Infinity answering false.

Mutation entries (scripts/mutate-dotnet.ps1): four new Caching entries
(integer-through-float64, decimal trailing zeros, exponent expansion,
NaN/Infinity) and five new Networking entries (the door's regex, the door's
explicit "-0" exclusion, the comparator's integer-length check, a
lexicographic-whole-string mutant, and the comparator's sign check) - all
KILLED. The two pre-existing S16 (le/ge boundary) entries had to be
re-anchored: their Old text quoted CompareNumber's previous double-based
`left <= right`/`left >= right` spelling, which no longer exists now that
CompareNumber switches on Compare's int result: `cmp <= 0`/`cmp >= 0`.

Verification: dotnet test - GameCult.Networking.Tests 192/192 (was 183 before
these additions), GameCult.Caching.Tests 192/192 (was 188), GameCult.Mesh.Tests
252/253 (one pre-existing unrelated skip, unaffected by this commit - no Mesh
source touched). Caching mutation run: 10/10 KILLED (6 pre-existing + 4 new),
M0 and final both green. Networking mutation run: 15/15 KILLED (10
pre-existing + 5 new, across CultNetSelectionEvaluator.cs, CultNetDatabase.cs
and CultNetSelection.cs), M0 and final both green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:18:22 +02:00
Meta Vens
4c33a62b29 CultNet selection cut1, commit 2e: mutation entries for the Mesh collapse
scripts/mutate-cultnet-selection-mesh.entries.ps1 covers the two rules moved in
commit 2d against tests/GameCult.Mesh.Tests: ResolveDefaultSelection's "no schema
filter when keys are given" default, and ReadDocumentFromSnapshotResponse's
recordKey-anchored exact read (including the foreign-schema-id decode fallback it
still honors).

M0: green, 87 passed (CultMeshStreamingTests). Verdicts:

- MESH-DEFAULT-Revert: KILLED
- MESH-DEFAULT-Loosening: SURVIVED
- MESH-KEY-Revert: KILLED
- MESH-KEY-Loosening: SURVIVED
- MESH-FOREIGN-Revert: KILLED
- Final: green, 87 passed. All restores byte-exact (working tree clean after the run).

Both SURVIVED entries are recorded as fixture gaps, not swapped for easier
mutants: no test in CultMeshStreamingTests.cs passes an explicit empty (non-null)
recordKeys array, and none varies a record key's case, so nothing distinguishes
MESH-DEFAULT-Loosening's `recordKeys != null` from the correct `recordKeys is
{ Count: > 0 }`, or MESH-KEY-Loosening's OrdinalIgnoreCase from the correct
Ordinal comparison. MESH-FOREIGN has a revert entry only; no distinct, still-wrong
"loosening" of "decode successfully as TDocument" was found that isn't already
one of the other two entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:54:36 +02:00
Meta Vens
5ba48f0ebe CultNet selection cut1, commit 2d: the CultMesh collapse
Collapses the rest of the Mesh selector surface named in docs/cultnet-selection-cut.md
section 4/11 onto CultNetSelection:

- CultMeshSnapshotRequestOptions becomes a record carrying Selection in place of
  SchemaIds/RecordKeys; its three hand-written clone functions
  (CloneSnapshotRequestOptions x2, CloneSnapshotFacadeRequestOptions) are gone in
  favor of `with`.
- CultMeshPeerSnapshotDocumentOptions becomes a record for the same reason;
  WithPublicationBindingOptions and ToSnapshotRequestOptions collapse to `with` and
  one inlined Selection literal at their single call site.
- CleanSnapshotFilter and the three-times "no schema filter when keys are given"
  rule collapse into two owners: CultMesh.ResolveDefaultSelection (the typed-document
  default) and CultMesh.OverlaySelection (explicit override over defaults).
- CultMeshHotBodySubscription carries one Selection instead of parallel
  RecordKeys/SchemaIds allowlists.
- ReadDocumentFromSnapshotResponse's four-tier fallback collapses to one
  recordKey-anchored read: exact schema/alias match, or (foreign/runtime-generated
  schema id) a payload that decodes as TDocument. The two tiers that matched by
  schema alone or recordKey alone - the ones that could return a record under the
  wrong key - are gone; DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments
  pins the surviving recordKey-anchored decode fallback.

Two items in section 11's table are not cut here, by finding rather than oversight:

- IsSameCultDocumentSchema (CultMesh.cs) already delegates to the one
  CultNetSchemaAliasMatching owner (no second engine); deleting the wrapper would
  duplicate its type-to-descriptor resolution at four call sites instead of
  collapsing it. Left in place.
- The selector literals in CultMeshClient.cs (574-604, 830-860, 932-962) call
  CultNetDatabaseSubscriptionClient.SubscribeAsync, which stays a v0
  (schemaIds/recordKeys) API in this cut - that client is not named in section 7's
  per-file plan. There is no Selection to move these onto without adding a v1
  subscribe path to Networking, which is out of this commit's scope. Left in place.

Tests: GameCult.Mesh.Tests 252/253 (1 pre-existing unrelated skip), 0 failed.
GameCult.Networking.Tests 161/161 excluding one pre-existing flaky test
(DatabaseSubscriptionServer_ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges,
a background RUDP poll thread racing a disposed socket in code this commit does not
touch; it crashes the test host the same way on this branch before this commit too).
GameCult.Caching.Tests 188/188.

Net line count, src/GameCult.Mesh only: +143/-226, net -83. Section 11 estimated
about -326 for this cut's slice. The gap is mostly the two items above (about -49
of the estimate) plus one already-ruled keep this map's Progress block already
named (the foreign-schema-id decode fallback, -41 in the table, kept per the prior
Hands' ruling and untouched here) and the four-tier fallback surviving as a
two-tier one rather than a zero-fallback exact read (an honest test-enforced
requirement, not a padding choice).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:38:56 +02:00
Meta Vens
4167cc4f24 CultNet selection cut1, commit 2c: mutation entry for the fourth matcher's collapse
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22) required a mutation
entry for the fourth schema-matching copy's collapse into
CultNetSchemaAliasMatching, landed in commit 2a. Added
NET-D4-FourthMatcher-Revert, pinned by
CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias: killed.

Every pre-existing entry in this file now carries its own File, so the whole
file runs as one invocation against both CultNetSelectionEvaluator.cs and
CultNetDatabase.cs (it previously assumed a single implicit target and would
throw given two). Ran the combined file: 10/10 entries killed, M0 green, both
targets restored byte-exact (verified by git status showing no diff on
either .cs file after the run).

A loosening mutant for the same site
('(requestedSchemaIds != null && requestedSchemaIds.Length == 0) ||', i.e.
reject only an empty-but-non-null requested-schema list) was run by hand and
SURVIVED: CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias
puts exactly one document in the whole cache, so an over-permissive filter
still returns the same single row. Recorded as a comment in the entries file
per the brief ("report it, do not replace it with an easier mutant") rather
than committed as a fake kill or swapped for an easier mutant. Fixing it needs
a second document of a different schema in the shard's fixture; not added
here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:05:34 +02:00
Meta Vens
c5b03cb455 CultNet selection cut1, commit 2b: collapse IsSameCultDocumentSchema into the one alias matcher
CultMesh.IsSameCultDocumentSchema (CultMesh.cs) and the decode-time alias check
in CultMeshSnapshots.DecodeSnapshotDocuments both re-implemented schema-identity
comparison independently of CultNetSchemaAliasMatching, the one matcher landed
in commit 1. Both now call CultNetSchemaAliasMatching.Matches; the private
InferSchemaName duplicated at CultMeshSnapshots.cs is deleted (Matches already
applies the same .vN inference internally).

Spec discrepancy, reported per the Hands brief (map contradicted by the Body):
docs/cultnet-selection-cut.md section 4 lists CultMeshSnapshots.cs:1033-1047
(RawSnapshotPayloadMatchesSchema) and :1078-1088 (InferSchemaName) as deletions,
on the stated reasoning that decode no longer needs a client-side re-filter
because the server's v1 answer is exact. Deleting RawSnapshotPayloadMatchesSchema
outright breaks two existing, still-relevant Mesh tests:
SnapshotHelpers_DecodeSameSchemaAliasFromForeignSchemaId and
SnapshotEndpoint_SyncsCompatibleForeignSchemaIdIntoLocalCache
(CultMeshStreamingTests.cs). Both cover a wire schemaId this runtime cannot
resolve by any alias at all (a foreign/runtime-generated id, e.g.
"runtime.generated.mesh-note.ui.42") where the only way to decode is to trust
the payload's own embedded schemaVersion. That is a decode capability, not a
re-filter of an already-exact server answer, and the map's stated reason for
deleting it does not apply to it. Smallest change keeping the map's intent
true: kept RawSnapshotPayloadMatchesSchema and TryReadSchemaVersion, deleted
only the duplicate InferSchemaName inside it and routed the comparison through
CultNetSchemaAliasMatching.Matches.

Test counts, no regressions: Networking 162/162, Mesh 252/253 (the one
pre-existing skip), Caching 188/188.

Negative grep: MatchesRequestedSchema empty. The one remaining
"MatchesSchema(" grep hit is RawSnapshotPayloadMatchesSchema's own name and
declaration, kept for the reason above.

Still outstanding from the map's Mesh collapse (§4/§11, ~365 gross lines):
the five cloned CultMeshSnapshotRequestOptions-carrying option records, the
"no schema filter when keys are given" rule (x3), CleanSnapshotFilter, the
four-tier ReadDocumentFromSnapshotResponse fallback, and every selector
literal in CultMeshClient.cs/CultMeshBodyDemand.cs/CultMesh.cs peer-snapshot
and body-subscribe construction. Those require CultMeshSnapshotRequestOptions
(and the v0 wire builders that read it) to carry a Selection instead of
separate SchemaIds/RecordKeys — a wider, structural change across Mesh's
public surface that is not done in this commit. Recorded as the next slice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:54:53 +02:00
Meta Vens
0787608cef CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.

Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.

Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).

Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
Meta Vens
17d10e017f CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).

Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.

Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
  cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
  replaces the reflection entirely. v0 lowers into a Selection and walks the
  evaluator's own pages so a v0 caller still sees its whole matching set
  (D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
  ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
  MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
  v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
  CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
  (92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
  CreateProjectedSnapshot's collapse are commit 2's, per the map's own
  deletion line, and are untouched here.

Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.

Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.

Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).

Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.

scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
Meta Vens
e420410fe9 CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).

Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.

GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).

Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.

scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +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
254 changed files with 28803 additions and 3268 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

@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.database-subscribe.v1.schema.json",
"title": "CultNet Database Subscribe Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_subscribe.v1" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"includeSnapshot": { "type": "boolean", "default": true },
"consumerRuntimeId": { "type": "string", "minLength": 1 },
"bodyIds": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"supportedBodyTransports": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,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

@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.error.schema.json",
"title": "CultNet Error Message",
"description": "R-X (docs/cultnet-selection-cut.md, fix batch 4): CultNetErrorMessage is a MessagePack-CSharp map-mode object, so every declared key is written on the wire, nil included - routingHint, code and details are `required` here the same as cultnet.raw-document-header.schema.json's nullable fields, not optional just because a given refusal leaves them null.",
"type": "object",
"required": [
"schemaVersion",
"error",
"routingHint",
"code",
"details"
],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.error.v0" },
"error": { "type": "string", "minLength": 1 },
"routingHint": {
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["shard", "reason"],
"properties": {
"shard": { "type": ["object", "null"] },
"reason": { "type": "string" }
}
},
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
"type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}
}
}
}

View file

@ -1,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

@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-header.schema.json",
"title": "CultNet Raw Document Header",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): CultNetRawDocumentRecord minus payload/payloadEncoding - CultNetRawDocumentHeader has no such members at all, so a header must not $ref the record schema (which requires them). Matches MessagePack-CSharp's map-mode output: every declared key is written, nil included, so every property here is `required` even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -0,0 +1,43 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.raw-document-record.schema.json",
"title": "CultNet Raw Document Record",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetRawDocumentRecord - every declared key is written, nil included, so every property here is `required` (present on the wire) even where its value may be null.",
"type": "object",
"required": [
"schemaId",
"schemaName",
"schemaVersion",
"schemaContentHash",
"recordKey",
"storedAt",
"payloadEncoding",
"payload",
"sourceRuntimeId",
"sourceAgentId",
"sourceRole",
"tags"
],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"schemaName": { "type": ["string", "null"], "minLength": 1 },
"schemaVersion": { "type": ["string", "null"], "minLength": 1 },
"schemaContentHash": { "type": ["string", "null"], "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 },
"storedAt": { "type": "string", "minLength": 1 },
"payloadEncoding": { "const": "messagepack" },
"payload": {
"type": "string",
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
},
"sourceRuntimeId": { "type": ["string", "null"], "minLength": 1 },
"sourceAgentId": { "type": ["string", "null"], "minLength": 1 },
"sourceRole": { "type": ["string", "null"], "minLength": 1 },
"tags": {
"type": ["array", "null"],
"items": { "type": "string", "minLength": 1 }
}
}
}

View file

@ -1,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

@ -0,0 +1,100 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.selection.schema.json",
"title": "CultNet Typed Selection",
"type": "object",
"additionalProperties": false,
"properties": {
"schemas": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1,
"description": "Absent means every schema. Present and empty is refused at the door (selection_invalid), never treated as \"every schema\": omit the field instead."
},
"keys": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"minItems": 1,
"description": "Absent means every key. Present and empty is refused at the door (selection_invalid), never treated as \"every key\": omit the field instead."
},
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/fieldPredicate" }
},
"cites": { "$ref": "#/$defs/citation" },
"cited": { "$ref": "#/$defs/incoming" },
"projection": {
"type": "string",
"enum": ["header", "document"],
"default": "header"
},
"descending": { "type": "boolean", "default": false },
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"description": "Clamped to 1..=200 by the answering server, not refused outside that range: an absent value or one outside the bound is treated as the nearer bound (absent = 200)."
},
"cursor": { "type": "string" }
},
"$defs": {
"recordRef": {
"type": "object",
"required": ["schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaId": { "type": "string", "minLength": 1 },
"recordKey": { "type": "string", "minLength": 1 }
}
},
"fieldPredicate": {
"type": "object",
"required": ["index", "op"],
"additionalProperties": false,
"properties": {
"index": { "type": "string", "minLength": 1 },
"op": { "type": "string", "enum": ["any_of", "lt", "le", "ge", "gt"] },
"values": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"number": {
"type": "string",
"description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.",
"pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$"
}
},
"oneOf": [
{
"required": ["values"],
"not": { "required": ["number"] },
"properties": { "op": { "const": "any_of" } }
},
{
"required": ["number"],
"not": { "required": ["values"] },
"properties": { "op": { "enum": ["lt", "le", "ge", "gt"] } }
}
]
},
"citation": {
"type": "object",
"required": ["target"],
"additionalProperties": false,
"properties": {
"target": { "$ref": "#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 }
}
},
"incoming": {
"type": "object",
"required": ["role", "exists"],
"additionalProperties": false,
"properties": {
"role": { "type": "string", "minLength": 1 },
"exists": { "type": "boolean" }
}
}
}
}

View file

@ -1,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

@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-request.v1.schema.json",
"title": "CultNet Snapshot Request Message v1",
"type": "object",
"required": ["schemaVersion", "messageId", "selection"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_request.v1" },
"messageId": { "type": "string", "minLength": 1 },
"selection": { "$ref": "cultnet.selection.schema.json" },
"shardId": { "type": "string", "minLength": 1 },
"shardEpoch": { "type": "integer" }
}
}

View file

@ -1,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

@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json",
"title": "CultNet Snapshot Response Raw Message v1",
"description": "R-S (docs/cultnet-selection-cut.md, fix batch 3): matches MessagePack-CSharp's map-mode output for CultNetSnapshotResponseRawV1Message - every declared key is written, nil included, so every property is `required` (present on the wire) even where its value may be null. `headers`/`documents` are still exactly one populated by projection (C5), but the OTHER key is present too, as an explicit null, not absent - the prior `oneOf: [{required:[headers]},{required:[documents]}]` matched both branches on every real page (both keys always present) and so never validated at all.",
"type": "object",
"required": [
"schemaVersion", "messageId", "matched", "asOf", "next",
"headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"
],
"additionalProperties": false,
"oneOf": [
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "array" }, "documents": { "type": "null" } }
},
{
"required": ["headers", "documents"],
"properties": { "headers": { "type": "null" }, "documents": { "type": "array" } }
}
],
"properties": {
"schemaVersion": { "const": "cultnet.snapshot_response_raw.v1" },
"messageId": { "type": "string", "minLength": 1 },
"matched": { "type": "integer", "minimum": 0 },
"asOf": { "type": "integer", "minimum": 0 },
"next": { "type": ["string", "null"] },
"headers": {
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-header.schema.json" }
},
"documents": {
"type": ["array", "null"],
"items": { "$ref": "cultnet.raw-document-record.schema.json" }
},
"edges": {
"type": ["array", "null"],
"items": {
"type": "object",
"required": ["from", "role", "to", "payloadEncoding", "payload"],
"additionalProperties": false,
"properties": {
"from": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"role": { "type": "string", "minLength": 1 },
"to": { "$ref": "cultnet.selection.schema.json#/$defs/recordRef" },
"payloadEncoding": { "type": ["string", "null"] },
"payload": {
"type": ["string", "null"],
"contentEncoding": "base64",
"x-cult-runtime-type": "bytes"
}
}
}
},
"shardId": { "type": ["string", "null"], "minLength": 1 },
"shardEpoch": { "type": ["integer", "null"] },
"shardLogSequence": { "type": ["integer", "null"] }
}
}

View file

@ -1,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": [

View file

@ -0,0 +1,334 @@
{
"vectors": [
{
"name": "any_of_plus_ge_conjunction",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHOShKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvbqZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyoTWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "descending_with_limit",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8OlbGltaXQDpmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
],
"matched": 18,
"hasNext": true,
"expectedEdges": []
},
{
"name": "gt_numeric_boundary",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womd0pnZhbHVlc8CmbnVtYmVyoTGlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 4,
"hasNext": false,
"expectedEdges": []
},
{
"name": "incoming_exists_true",
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
],
"matched": 2,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
},
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "incoming_negation",
"selectionMessagePackBase64": "iadzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 7,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_index_value_three_rows",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKZudW1iZXLApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
],
"matched": 3,
"hasNext": false,
"expectedEdges": []
},
{
"name": "float_tie_exact_ge",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqjIyMzM3NTkuMjWlY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "hop_by_role_cites_exact_id",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "cites_target_by_name_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkadjaXRlci0xpmZpZWxkc8ClY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWSpbGVhZl9hLnY5qXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWdupWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "schemas_by_name_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzkalsZWFmX2Eudjmka2V5c8CmZmllbGRzwKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 9,
"hasNext": false,
"expectedEdges": []
},
{
"name": "astral_key_code_point_order",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "header_projection",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "document_projection",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26oZG9jdW1lbnSqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/\uD83D\uDE02-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHORhKVpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGmbnVtYmVywKVjaXRlc8ClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSmZmllbGRzwKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
"role": "narrow_ref",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmnZHVwLWtleaRyb2xlpkRlc2lnbqVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": null,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "refuses_empty_schemas_list",
"selectionMessagePackBase64": "iadzY2hlbWFzkKRrZXlzwKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "schemas",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_blank_key_entry",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkqRhLWVxoyAgIKZmaWVsZHPApWNpdGVzwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "keys",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_unmatched_cites_target_schema",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkvHNoYTI1Njpub3QtYS1kZWNsYXJlZC1zY2hlbWGpcmVjb3JkS2V5pGEtZXGkcm9sZcClY2l0ZWTAqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfCpWxpbWl0wKZjdXJzb3LA",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_cites_target_by_hash_shaped_alias",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzwKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UpzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZi52MalyZWNvcmRLZXmkYS1lcaRyb2xlwKVjaXRlZMCqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8KlbGltaXTApmN1cnNvcsA=",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedEvaluationRefusal": null,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
"selectionMessagePackBase64": "iadzY2hlbWFzwKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKZmaWVsZHPApWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmrbmFycm93LW9ubHmkcm9sZapuYXJyb3dfcmVmpWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywA==",
"asOf": 1,
"expectedRefusalField": null,
"expectedEvaluationRefusal": "reference_outside_target",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
}
]
}

View file

@ -0,0 +1,105 @@
{
"//": "CultNet typed selection, Cut 1 fix batch (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch, R-E and the shared-fixture ruling, 2026-09-22). The one row set both the C# reference and cultnet-rs build their parity vectors from. schemaId is each schema's real SHA-256 content-hash id, exactly as GameCult.Caching.CultDocumentRegistry computes it for the fixture's C# document types (leaf_a=ParityLeafA, leaf_b=ParityLeafB, citer=ParityCiter, citer_narrow=ParityCiterNarrow, tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs) - CultCache.cs's Sha256(semanticFingerprint), which carries no '.vN' suffix of its own; the version is folded into the hash, never appended as text. Both writers read this file instead of building their own fixture rows.",
"//aliases": "Two DIFFERENT alias forms, now deliberate rather than a cross-runtime defect (S-12, 2026-09-22: the Rust alias port made this so - see docs/cultnet-selection-cut.md, 'Self's ruling, 2026-09-22: the C# reference's alias rule is the rule'). Both runtimes resolve a schema alias the same one way: strip a trailing .vN and compare the remainder against the descriptor's declared SchemaName (CultNetSchemaAliasMatching.Matches(candidate, CultDocumentDescriptor), src/GameCult.Networking/CultNetDatabase.cs:74-87; cultnet_rs::selection::schema_alias::matches ports it name-for-name and deletes the hash-stripping overload it used to carry). nameAlias is <SchemaName>.v9 and resolves through both runtimes under that one rule. hashAlias is <schemaId>.v1 and is refused as unmatched by both - it is not a wire form either runtime's production call sites ever produce or accept, and it is kept in the fixture specifically to pin that neither runtime resolves it.",
"//references": "R-AF, 2026-09-23: a reference's `references[].targetKey` is the only thing a stored edge actually carries on the wire - GameCult.Caching.MessagePack.CultRecordRefFormatter<T> serializes a CultRecordRef<T> as a bare key string (probed: MessagePackSerializer.Serialize on a real Design field produced the single msgpack string 'a-eq', nothing else). This fixture used to also carry a `targetSchema` per reference; it was never anything but documentation - SelectionParityVectorTests.BuildDocument and cultnet-rs's build_row both read only targetKey to construct the edge - and it is removed rather than kept as a field the wire does not have. Resolving which row an edge names is by targetKey alone, filtered to the declared role's target leaves (D9); see citer_narrow below for the shared-key shape that rule is pinned against.",
"schemas": {
"leaf_a": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"nameAlias": "leaf_a.v9",
"hashAlias": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff.v1",
"indexes": [
{ "name": "kind", "numeric": false },
{ "name": "mass", "numeric": true }
]
},
"leaf_b": {
"schemaId": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42",
"nameAlias": "leaf_b.v9",
"hashAlias": "sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42.v1",
"indexes": [
{ "name": "kind", "numeric": false },
{ "name": "mass", "numeric": true }
]
},
"citer": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"nameAlias": "citer.v9",
"hashAlias": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc.v1",
"roles": [
{ "name": "Design", "targets": ["leaf_a", "leaf_b"] }
]
},
"citer_narrow": {
"//": "R-AF: a citer whose declared reference target is one leaf only (leaf_a), so a record key shared by a leaf_a row and a leaf_b row has exactly one candidate inside the declared target and exactly one outside it - the shape neither runtime's parity vectors covered before this cut (zero duplicate record keys across the original ten rows).",
"schemaId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d",
"nameAlias": "citer_narrow.v9",
"hashAlias": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d.v1",
"roles": [
{ "name": "narrow_ref", "targets": ["leaf_a"] }
]
}
},
"rows": [
{ "schema": "leaf_a", "key": "a-lo", "ordinal": 1, "fields": { "kind": "weapon", "mass": "4" } },
{ "schema": "leaf_a", "key": "a-eq", "ordinal": 2, "fields": { "kind": "weapon", "mass": "5" } },
{ "schema": "leaf_b", "key": "b-hi", "ordinal": 3, "fields": { "kind": "armor", "mass": "6" } },
{ "schema": "leaf_a", "key": "shared-1", "ordinal": 4, "fields": { "kind": "shared", "mass": "1" } },
{ "schema": "leaf_b", "key": "shared-2", "ordinal": 5, "fields": { "kind": "shared", "mass": "1" } },
{ "schema": "leaf_a", "key": "shared-3", "ordinal": 6, "fields": { "kind": "shared", "mass": "1" } },
{
"schema": "citer",
"key": "citer-1",
"ordinal": 7,
"references": [ { "role": "Design", "targetKey": "a-eq" } ]
},
{
"//": "R-D: 2233759.25 is exactly representable as an f32 and sits on a round-to-even tie between two shortest-form spellings; its exact canonical decimal is 2233759.25 either way. kind=tie so no weapon/armor/shared case can sweep it up.",
"schema": "leaf_a", "key": "tie-mass", "ordinal": 8, "fields": { "kind": "tie", "mass": "2233759.25" }
},
{
"//": "R-C: BMP private-use key (U+E000), same ordinal as the astral row below so the tiebreak - not the ordinal - decides order. kind=unicode, mass=0 so no unfiltered or mass>1 case reaches these.",
"schema": "leaf_a", "key": "-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
},
{
"//": "R-C: astral key (U+1F602, encoded as a surrogate pair whose high half 0xD83D is less than 0xE000 - a UTF-16 code-unit compare sorts this first; code-point order sorts it after the BMP row above).",
"schema": "leaf_a", "key": "😂-row", "ordinal": 9, "fields": { "kind": "unicode", "mass": "0" }
},
{
"//": "R-AF shape 1: the only row at this key is outside citer_narrow's declared target (leaf_a only) - resolving the edge below must refuse reference_outside_target in both runtimes, not silently accept an empty page. kind=narrow-fixture (not \"shared\") so this row never sweeps into shared_index_value_three_rows, which pins an exact count of 3.",
"schema": "leaf_b", "key": "narrow-only", "ordinal": 10, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"//": "R-AF shape 2: two rows share this key, one inside citer_narrow's declared target and one outside it - resolving the edge below must accept, deterministically picking the leaf_a row, in both runtimes.",
"schema": "leaf_a", "key": "dup-key", "ordinal": 11, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"schema": "leaf_b", "key": "dup-key", "ordinal": 12, "fields": { "kind": "narrow-fixture", "mass": "1" }
},
{
"schema": "citer_narrow",
"key": "narrow-citer-refused",
"ordinal": 13,
"references": [ { "role": "narrow_ref", "targetKey": "narrow-only" } ]
},
{
"schema": "citer_narrow",
"key": "narrow-citer-accepted",
"ordinal": 14,
"references": [ { "role": "narrow_ref", "targetKey": "dup-key" } ]
},
{
"//": "R-AJ: a tied ordinal across two DIFFERENT schemas (leaf_a and leaf_b), not two rows of the same leaf like the astral/BMP pair above - the shape the row order's schemaId tiebreak needs and the original ten rows never had. Keys are anti-correlated with the real schema ids on purpose: leaf_b's real id sorts before leaf_a's (sha256:b1f2... < sha256:ff35...), so leaf_b gets the alphabetically-*later* key (\"zz-tie-schema\") and leaf_a gets the *earlier* one (\"aa-tie-schema\") - a schemaId-first order and a recordKey-first order disagree here on purpose, so only the correct composition produces leaf_b's row first.",
"schema": "leaf_b", "key": "zz-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
},
{
"schema": "leaf_a", "key": "aa-tie-schema", "ordinal": 15, "fields": { "kind": "tie-schema", "mass": "1" }
},
{
"//": "R-AO, 2026-09-23: 'dup-key' already carries two rows (leaf_a ordinal 11, leaf_b ordinal 12) - both are in citer's Design role's declared target ([leaf_a, leaf_b], unlike citer_narrow's narrow_ref above, which only ever had one in-target candidate at a shared key). This citer is the first row in this file (hence in both runtimes' row order) to name 'dup-key' through Design, so resolving it must deterministically pick the leaf_a row - the first in-target candidate in the caller's order - never the leaf_b row, in both runtimes.",
"schema": "citer",
"key": "citer-both-in-target",
"ordinal": 16,
"references": [ { "role": "Design", "targetKey": "dup-key" } ]
}
]
}

View file

@ -0,0 +1,282 @@
{
"vectors": [
{
"name": "any_of_plus_ge_conjunction",
"selectionMessagePackBase64": "g6ZmaWVsZHOSg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpndlYXBvboOlaW5kZXikbWFzc6JvcKJnZaZudW1iZXKhNapwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "descending_with_limit",
"selectionMessagePackBase64": "g6pwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nw6VsaW1pdAM=",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema"
],
"matched": 18,
"hasNext": true,
"expectedEdges": []
},
{
"name": "gt_numeric_boundary",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womd0pm51bWJlcqExqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/b-hi",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 4,
"hasNext": false,
"expectedEdges": []
},
{
"name": "incoming_exists_true",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8OqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key"
],
"matched": 2,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
},
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "incoming_negation",
"selectionMessagePackBase64": "hKdzY2hlbWFzkdlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmalY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 7,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_index_value_three_rows",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORpnNoYXJlZKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/shared-2",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3"
],
"matched": 3,
"hasNext": false,
"expectedEdges": []
},
{
"name": "hop_by_role_cites_exact_id",
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "hop_by_role_cites_name_alias",
"selectionMessagePackBase64": "hKRrZXlzkadjaXRlci0xpWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlkqWxlYWZfYS52OalyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-1",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"payloadBase64": null
}
]
},
{
"name": "schemas_by_hash_alias",
"selectionMessagePackBase64": "g6dzY2hlbWFzkdlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "schemas_by_name_alias",
"selectionMessagePackBase64": "g6dzY2hlbWFzkalsZWFmX2EudjmqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-lo",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/a-eq",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-1",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/shared-3",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 9,
"hasNext": false,
"expectedEdges": []
},
{
"name": "float_tie_exact_ge",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRtYXNzom9womdlpm51bWJlcqoyMjMzNzU5LjI1qnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/tie-mass"
],
"matched": 1,
"hasNext": false,
"expectedEdges": []
},
{
"name": "astral_key_code_point_order",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORp3VuaWNvZGWqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/-row",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/😂-row"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "row_tiebreak_schema_id_before_record_key_at_tied_ordinal",
"selectionMessagePackBase64": "g6ZmaWVsZHORg6VpbmRleKRraW5kom9wpmFueV9vZqZ2YWx1ZXORqnRpZS1zY2hlbWGqcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:b1f2407d0227a79dcbffdf7ea317f827eec64be38a3f61b7d4e99e81e3b24b42/zz-tie-schema",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/aa-tie-schema"
],
"matched": 2,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_resolves_leaf_within_target",
"selectionMessagePackBase64": "hKRrZXlzkbVuYXJyb3ctY2l0ZXItYWNjZXB0ZWSlY2l0ZXOCpnRhcmdldIKoc2NoZW1hSWTZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmqXJlY29yZEtleadkdXAta2V5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedIds": [
"sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:ee192f6526a8f8cdec7044551aa2c8e28c612426766bb270605023bc94e2614d/narrow-citer-accepted",
"role": "narrow_ref",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "shared_key_design_resolves_the_first_in_target_candidate_in_row_order",
"selectionMessagePackBase64": "hKRrZXlzkbRjaXRlci1ib3RoLWluLXRhcmdldKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5p2R1cC1rZXmkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedIds": [
"sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target"
],
"matched": 1,
"hasNext": false,
"expectedEdges": [
{
"fromId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc/citer-both-in-target",
"role": "Design",
"toId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff/dup-key",
"payloadBase64": null
}
]
},
{
"name": "refuses_empty_schemas_list",
"selectionMessagePackBase64": "g6dzY2hlbWFzkKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedRefusalField": "schemas",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_blank_key_entry",
"selectionMessagePackBase64": "g6RrZXlzkqRhLWVxoyAgIKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedRefusalField": "keys",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_unmatched_cites_target_schema",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZLlzaGEyNTY6bm90LWEta25vd24tc2NoZW1hqXJlY29yZEtleaRhLWVxpHJvbGWmRGVzaWduqnByb2plY3Rpb26maGVhZGVyqmRlc2NlbmRpbmfC",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "refuses_cites_target_by_hash_shaped_alias",
"selectionMessagePackBase64": "g6VjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlKc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmYudjGpcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26qcHJvamVjdGlvbqZoZWFkZXKqZGVzY2VuZGluZ8I=",
"asOf": 1,
"expectedRefusalField": "cites.target.schemaId",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
},
{
"name": "shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target",
"selectionMessagePackBase64": "hKRrZXlzkbRuYXJyb3ctY2l0ZXItcmVmdXNlZKVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5q25hcnJvdy1vbmx5pHJvbGWqbmFycm93X3JlZqpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwg==",
"asOf": 1,
"expectedEvaluationRefusal": "reference_outside_target",
"expectedIds": [],
"matched": 0,
"hasNext": false,
"expectedEdges": []
}
]
}

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
```

View file

@ -62,6 +62,17 @@ to speak CultNet or participate in CultMesh.
shared transport port and expand RUDP adoption beyond the core proof.
- `packages/cultmesh-kotlin/README.md` records Kotlin's runtime role and
ergonomic surface, including the explicit non-claim around C# SoA storage.
- CultNet typed selection (`cultnet.snapshot_response_raw.v1`, C#/Rust only;
docs/cultnet-selection-cut.md, R-AY): byte parity for the
`snapshot_response_raw.v1` envelope and its nested record types, verified at
populated and absent optional values, plus semantic parity over the named
field subset. `packages/cultnet-rs/tests/selection_wire_parity.rs` and
`tests/GameCult.Networking.Tests/SelectionWireParityTests.cs` carry the byte
vectors; `packages/cultnet-rs/tests/selection.rs`'s
`write_selection_vectors_for_the_reference` and
`tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs` carry the
semantic ones. A single rows-to-bytes path driving both halves in one call
remains its own cut (R-AR) and is not part of this claim.
## Expansion Work Outside The Current Parity Claim

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,94 @@
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(NOT WIN32)
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")
endif()
endif()

View file

@ -1,10 +1,147 @@
# 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, `setarch`, and Node, because the
mutation harness that drives the runtime-lifetime scenarios is a Node script and
a container that cannot run it cannot check the bridge it just built:
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 && node scripts/mutate-cultmesh.mjs native"
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. The second is one line
because the shell it is offered to is not decided here; a continuation would have
to pick one, and a backtick pasted into a POSIX shell is not a continuation.
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. The
ThreadSanitizer configuration needs the process's address space where it expects
it, so `scripts/mutate-cultmesh.mjs` re-executes those runs 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, which a
mutation harness would otherwise read as every mutant being killed. The harness
stops with that diagnosis rather than reporting kills it did not earn.
The win32-x64 half of the harness 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 for every mutant including the no-op control.
The harness stops on a red control rather than reporting a table of kills, so
the failure is loud, but it names MSBuild and not the path.
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,350 @@
/* 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 a
* scenario that can only sleep and hope cannot tell a bridge that counts them
* 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);
#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */
#if defined(__cplusplus)
}
#endif
#endif /* CULTMESH_QUIC_NATIVE_H */

View file

@ -0,0 +1,706 @@
// Scenario runner for the bridge's runtime lifetime rules.
//
// This is the one part of the bridge a host cannot check by reading: whether
// `cultmesh_quic_runtime_close` is safe against host threads that are inside the
// library when it starts. Everything here drives that path and nothing else. It
// opens a runtime, parks pollers in `cultmesh_quic_next_event`, closes the
// runtime underneath them, and asserts what the header promises: every parked
// poller returns, and none of them touches the runtime after it is gone.
//
// It consumes the bridge through include/cultmesh_quic_native.h — the same
// contract a host has — and nothing else. No MsQuic header, no internals.
//
// The failures it exists to catch are not visible in the return values, because
// a use-after-free is not an error code. Two things make them visible instead:
// a sanitizer build (ThreadSanitizer for the notify/destroy race, AddressSanitizer
// for the freed-runtime dereference), and `CULTMESH_QUIC_DEBUG_ASSERTS`, which
// makes the bridge state the quiesce invariant itself rather than leaving it to
// be caught by an unlucky thread schedule.
//
// cultmesh_quic_native_tests closerace [iterations] [pollers]
// cultmesh_quic_native_tests holdclose [iterations] [pollers]
// cultmesh_quic_native_tests polltimeout [iterations]
// cultmesh_quic_native_tests latecall [iterations]
// cultmesh_quic_native_tests holdtimeout [iterations]
//
// `closerace` hunts the race: many pollers, many iterations, and whatever the
// schedule gives. `holdclose` takes the guessing out of it — the bridge's
// development seam (section 7 of the header) holds the pollers inside the
// library until this scenario releases them, and reports how many host calls the
// bridge actually counted, so the scenario asserts the quiesce instead of
// assuming its own fixture worked. It needs CULTMESH_QUIC_DEBUG_ASSERTS.
//
// In both of those the wait is ended by the close, so the host's timeout governs
// nothing either of them can see, and a bridge waiting on a constant of its own
// passes both. `polltimeout` is the one that lets the timeout govern the return
// and measures it. `latecall` covers the other half of section 4: a call that
// races the start of a close is refused rather than counted behind its wait.
// `holdtimeout` covers the seam's own rule, which nothing else is in a position
// to see: the hold parks a call the wait woke and not one whose own timeout
// expired, so the quiesce the hold scenarios assert is the bridge's doing and
// not the fixture's.
//
// Exit code 0 means every assertion held. Anything else, including a sanitizer
// abort or the bridge's own assertion, is a failure. Under ThreadSanitizer run
// it with `TSAN_OPTIONS=halt_on_error=1`, or a reported race leaves the exit
// code to the last thing that set it.
#include <cultmesh_quic_native.h>
#include <array>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
namespace {
// Long enough that a poller is still parked when the close begins. The close is
// what ends the wait; this timeout only bounds the scenario if the close never
// arrives, which is itself the failure.
constexpr int32_t kPollTimeoutMs = 5000;
// A poller asks for a payload buffer, so the close races a `next_event` that has
// somewhere to copy into rather than one that can only ever return 0.
constexpr int32_t kPayloadCapacity = 4096;
int Fail(const std::string& message) {
std::fprintf(stderr, "FAILED: %s\n", message.c_str());
return 1;
}
// One iteration: `pollers` threads parked in `next_event`, then a close under
// them. Returns an empty string when every promise held.
//
// Each poller makes exactly one blocking call and does not call again. Section 5
// of the header puts the ordering on the host — no call may *begin* during a
// close — so a scenario that kept re-entering would be testing a shape the
// bridge declines to support, and its report would be the harness's own fault.
// A call already inside the library is the case the quiesce exists for, and that
// is the one run here.
std::string CloseRaceOnce(int pollers) {
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr)
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
// Counts threads about to enter the blocking call. The close waits for all of
// them, so the race is run with the pollers actually inside rather than with
// threads that have not started yet.
std::atomic<int> entering{0};
std::atomic<int> returned{0};
std::atomic<int> undefined_return{0};
std::atomic<int> timed_out{0};
// Set just before the close, and read by each poller as it returns. A poller
// that is already out by then was never in the race, and a scenario full of
// those passes without testing anything: this is what catches a bridge whose
// blocking crossing has stopped blocking.
std::atomic<bool> closing_started{false};
std::atomic<int> left_before_the_close{0};
std::vector<std::thread> threads;
threads.reserve(static_cast<size_t>(pollers));
for (int index = 0; index < pollers; ++index) {
threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out,
&closing_started, &left_before_the_close] {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
entering.fetch_add(1);
const auto started = std::chrono::steady_clock::now();
// Parked here when the close arrives. `closing` wakes the wait; the
// call scope this thread holds is what the close must count out
// before it frees anything.
const int32_t result = cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started).count();
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
// The close is what ends this wait. Sitting out the whole timeout
// instead means the wake never reached it.
if (elapsed >= kPollTimeoutMs / 2) timed_out.fetch_add(1);
if (!closing_started.load()) left_before_the_close.fetch_add(1);
returned.fetch_add(1);
});
}
while (entering.load() < pollers) std::this_thread::yield();
// Settle so the pollers are inside the blocking wait rather than on their way
// to it. A poller that has not entered yet is a call beginning during the
// close, which is the host's to avoid, not the bridge's to survive.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
closing_started.store(true);
cultmesh_quic_runtime_close(runtime);
for (auto& thread : threads) thread.join();
if (left_before_the_close.load() != 0)
return std::to_string(left_before_the_close.load()) + " of " + std::to_string(pollers) +
" pollers returned before the close began, so they were never in the race";
if (undefined_return.load() != 0)
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
if (timed_out.load() != 0)
return std::to_string(timed_out.load()) + " of " + std::to_string(pollers) +
" pollers sat out the timeout instead of being woken by the close";
if (returned.load() != pollers)
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
return {};
}
// The two timeouts `polltimeout` asks for. Two, and not one, because a single
// value cannot tell a bridge that honours the host's timeout from one that waits
// on that same number of its own: a constant has to satisfy both bands, and no
// constant is in both.
//
// A constant was never the hard case, though, and the numbers are chosen for the
// waits that are computed from the argument instead. Any mapping that is the
// identity at both of these passes for free, so the two are picked to leave the
// ordinary ones nowhere to be identity:
//
// - 5000 is above any ceiling a bridge would plausibly clamp a host's timeout
// to. A wait of `min(timeout, 1000)` — cap it so a shutdown gets noticed — is
// the most ordinary spelling this line will ever be given, and it returns
// from this probe four seconds early.
// - 200 is low enough that an added constant shows as a proportion. A wait of
// `timeout + 150` is 75% late here, and the late tolerance below is a
// fraction of what was asked for rather than a flat number it could hide in.
constexpr int32_t kShortPollMs = 200;
constexpr int32_t kLongPollMs = 5000;
// How much sooner than the timeout a poll may return. A wait may be late; it may
// not be early, because returning early is exactly what waiting on a shorter
// duration looks like. This is scheduler granularity, not slack.
constexpr int kEarlyToleranceMs = 20;
// How much later than the timeout a poll may return: an allowance for scheduler
// granularity plus an eighth of what was asked for.
//
// Proportional, and not the flat 300 ms this used to be, because a flat
// tolerance is a gap a mapping hides in. `timeout + k` for any k under the flat
// value passes at every probe, at every value, forever — and the measured
// overshoot on both targets is about 10 ms, so a flat 300 was 290 ms of room
// for a bridge to add a little of its own and be believed.
//
// The honest limit: a scaling smaller than an eighth survives this, and this
// does not claim otherwise. An eighth is under the smallest scaling anyone
// writes on purpose, and 60 ms is six times the overshoot either target shows.
constexpr int LateToleranceMs(int32_t timeout_ms) { return 60 + timeout_ms / 8; }
struct TimedPoll {
int32_t result;
long long elapsed_ms;
};
TimedPoll PollFor(void* runtime, int32_t timeout_ms) {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
const auto started = std::chrono::steady_clock::now();
const int32_t result = cultmesh_quic_next_event(
runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started).count();
return {result, elapsed};
}
// The host's timeout, and the only thing it is: how long a poll with nothing to
// deliver stays inside the library. Nothing else here checks it — `closerace`
// and `holdclose` both end their waits with a close, so in both of them the
// bridge could wait on any duration at all and every assertion would still hold.
// A runtime with no listener and no connection has nothing to deliver, so the
// timeout is what ends this poll, and the elapsed time is what it waited on.
std::string PollTimeoutOnce() {
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr)
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
std::string failure;
for (const int32_t timeout_ms : {kShortPollMs, kLongPollMs}) {
const TimedPoll poll = PollFor(runtime, timeout_ms);
const std::string asked = "a poll asking for " + std::to_string(timeout_ms) + " ms ";
if (poll.result != 0) {
failure = asked + "returned " + std::to_string(poll.result) +
", not the 0 an idle runtime owes it";
break;
}
if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs) {
failure = asked + "returned after " + std::to_string(poll.elapsed_ms) +
" ms: the wait ended on some shorter duration than the one it was given";
break;
}
if (poll.elapsed_ms > timeout_ms + LateToleranceMs(timeout_ms)) {
failure = asked + "returned after " + std::to_string(poll.elapsed_ms) +
" ms: the wait ended on some longer duration than the one it was given";
break;
}
}
cultmesh_quic_runtime_close(runtime);
return failure;
}
int PollTimeout(int iterations) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = PollTimeoutOnce();
if (!failure.empty())
return Fail("polltimeout iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("polltimeout %dx: ok\n", iterations);
return 0;
}
// Nothing listens on the discard port, so a connection to it fails and the
// bridge says why. That reason is the payload this scenario needs, and reaching
// it takes no listener, no credential and no established connection.
constexpr uint16_t kClosedPort = 9;
// How long the failure is given to arrive. The loopback refusal is immediate;
// if the refusal is swallowed instead, MsQuic's own handshake timeout ends the
// connection at 10 seconds and event 4 still arrives, so this only bounds a
// bridge that reports nothing at all.
constexpr int kEventDeadlineMs = 20000;
// The bridge writes its own reason sentences and every one of them starts here.
// Checking the bytes, and not only the length, is what separates a payload
// copied out of the queued event from one copied out of an event that was popped
// first: the second reads freed memory, which does not spell this.
constexpr char kReasonPrefix[] = "CultMesh QUIC connection";
// The two-phase poll, which is the shape every host uses to size its buffer:
// ask with nothing, be told what it needs, ask again. What makes it work is that
// the refusal consumes nothing — the event is still queued, payload and all,
// after a return of 2 — and that the copy happens before the event is popped.
// Both are section 6 of the header, and nothing committed reached either.
std::string PayloadFitOnce() {
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr)
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
std::string failure;
uint64_t connection_id = 0;
const int32_t connecting =
cultmesh_quic_connection_open(runtime, "127.0.0.1", kClosedPort, &connection_id);
if (connecting != 0) {
cultmesh_quic_runtime_close(runtime);
return "cultmesh_quic_connection_open returned " + std::to_string(connecting);
}
// An out parameter the bridge must not write on a refusal, filled with
// something the bridge never writes so that a write is visible.
cultmesh_quic_event poisoned{};
std::memset(&poisoned, 0xab, sizeof(poisoned));
cultmesh_quic_event event = poisoned;
int32_t required = 0;
int32_t result = 0;
// Ask with no room at all until the event carrying the failure reason
// arrives. An event with no payload fits in nothing, so it is delivered and
// polled past rather than mistaken for this one.
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kEventDeadlineMs);
for (;;) {
event = poisoned;
required = 0;
result = cultmesh_quic_next_event(runtime, 250, &event, nullptr, 0, &required);
if (result == 2 && required > 0) break;
if (result < 0) {
failure = "cultmesh_quic_next_event returned " + std::to_string(result);
break;
}
if (std::chrono::steady_clock::now() >= deadline) {
failure = "no event carrying a payload arrived within " +
std::to_string(kEventDeadlineMs) + " ms of a connection to a closed port";
break;
}
}
if (failure.empty() && std::memcmp(&event, &poisoned, sizeof(event)) != 0)
failure = "the refusal wrote to *out_event, which the header says it leaves alone";
// Again, with the same empty buffer: a refusal that consumed the event
// leaves nothing to find the second time.
if (failure.empty()) {
cultmesh_quic_event again = poisoned;
int32_t required_again = 0;
const int32_t second = cultmesh_quic_next_event(runtime, 0, &again, nullptr, 0, &required_again);
if (second != 2 || required_again != required)
failure = "the second ask returned " + std::to_string(second) + " needing " +
std::to_string(required_again) + ", not the 2 needing " + std::to_string(required) +
" the first one did: the refusal consumed the event";
}
// And with room, which is where the copy happens. The tail beyond the
// payload is written too, so a copy that ran past what it was told to write
// is visible.
if (failure.empty()) {
constexpr uint8_t kTail = 0x5a;
std::vector<uint8_t> payload(static_cast<size_t>(required) + 16, kTail);
cultmesh_quic_event delivered = poisoned;
int32_t required_delivered = 0;
const int32_t third = cultmesh_quic_next_event(
runtime, 0, &delivered, payload.data(), static_cast<int32_t>(payload.size()),
&required_delivered);
const std::string reason(reinterpret_cast<const char*>(payload.data()),
static_cast<size_t>(required));
if (third != 1 || required_delivered != required)
failure = "the ask with room returned " + std::to_string(third) + " needing " +
std::to_string(required_delivered) + ", not the 1 needing " +
std::to_string(required) + " the refusal promised";
else if (delivered.type != CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN ||
delivered.connection_id != connection_id ||
delivered.payload_length != required)
failure = "the delivered event was type " + std::to_string(delivered.type) +
" on connection " + std::to_string(delivered.connection_id) + " carrying " +
std::to_string(delivered.payload_length) + " byte(s), not the failed connection's own";
else if (reason.rfind(kReasonPrefix, 0) != 0)
failure = "the payload delivered was '" + reason + "', not a reason this bridge writes";
else if (payload[static_cast<size_t>(required)] != kTail)
failure = "the copy wrote past the " + std::to_string(required) + " bytes it reported";
}
// Consumed now, and only now.
if (failure.empty()) {
cultmesh_quic_event emptied = poisoned;
int32_t required_emptied = 0;
const int32_t fourth =
cultmesh_quic_next_event(runtime, 0, &emptied, nullptr, 0, &required_emptied);
if (fourth != 0)
failure = "the event was still queued after being delivered: a further ask returned " +
std::to_string(fourth);
}
cultmesh_quic_runtime_close(runtime);
return failure;
}
int PayloadFit(int iterations) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = PayloadFitOnce();
if (!failure.empty())
return Fail("payloadfit iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("payloadfit %dx: ok\n", iterations);
return 0;
}
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
// How long a poll that does not block is given to prove it by returning, after
// the calls have been counted and before the close begins. A bridge whose
// blocking wait no longer blocks has emptied itself of host calls by the time
// the close starts, and `calls_at_close` then says 0.
constexpr int kSettleMs = 100;
// How long the close is required to stay inside `cultmesh_quic_runtime_close`
// while the pollers are held. The held calls are what it is waiting for, and
// nothing releases them until this elapses, so a close that returns first gave
// up on a call that was still inside. This bounds what the scenario can see: a
// close that waits for longer than this and then gives up is not distinguished
// from one that waits properly, which is the honest limit of the check.
constexpr int kHeldWindowMs = 500;
// How long the pollers are given to be counted inside the library before the
// close begins. Reaching `pollers` is the normal path; the deadline only stops
// a bridge that never counts them from hanging the scenario.
constexpr int kCountedDeadlineMs = 2000;
// One iteration of the quiesce itself, with the guesswork taken out: the pollers
// are held inside the library by the bridge's own development seam until this
// scenario lets them go, and the bridge says how many calls it counted rather
// than the scenario assuming the schedule put them there.
//
// Three promises, and a mutation that breaks any one of them is seen here:
// - the calls are counted (`peak`), so a blocking crossing outside a call scope
// is not merely uncounted-but-lucky, it reads as zero;
// - they were still counted when the close began its wait (`calls_at_close`),
// so a poll that stopped blocking cannot leave the scenario testing an empty
// library;
// - the close did not return while they were held, so a wait that gives up
// after any interval shorter than the hold is not a wait.
std::string HoldCloseOnce(int pollers) {
cultmesh_quic_debug_hold_calls(1);
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr) {
cultmesh_quic_debug_hold_calls(0);
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
}
std::atomic<int> returned{0};
std::atomic<int> undefined_return{0};
std::vector<std::thread> threads;
threads.reserve(static_cast<size_t>(pollers));
for (int index = 0; index < pollers; ++index) {
threads.emplace_back([runtime, &returned, &undefined_return] {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
const int32_t result = cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
returned.fetch_add(1);
});
}
const auto counted_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
while (cultmesh_quic_debug_peak_calls() < pollers &&
std::chrono::steady_clock::now() < counted_deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
std::atomic<bool> closed{false};
std::thread closer([runtime, &closed] {
cultmesh_quic_runtime_close(runtime);
closed.store(true);
});
const auto held_until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kHeldWindowMs);
while (!closed.load() && std::chrono::steady_clock::now() < held_until)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
const bool closed_while_held = closed.load();
const int32_t peak = cultmesh_quic_debug_peak_calls();
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
cultmesh_quic_debug_hold_calls(0);
closer.join();
for (auto& thread : threads) thread.join();
if (peak != pollers)
return "the library's peak count of host calls inside it was " + std::to_string(peak) + ", not " +
std::to_string(pollers) + ": either the blocking crossing is not inside a call scope, or it " +
"did not block long enough for the pollers to be inside it together";
if (at_close != pollers)
return "the close began its wait with " + std::to_string(at_close) +
" host call(s) counted inside, not " + std::to_string(pollers) +
"; the pollers were not inside the library when the close started";
if (closed_while_held)
return "the close returned while " + std::to_string(pollers) +
" host call(s) were still held inside the library";
if (undefined_return.load() != 0)
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
if (returned.load() != pollers)
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
return {};
}
// One iteration of the other half of section 4: a call made while a close is
// running is refused, and is not counted behind the close's wait.
//
// The prohibition is the host's — no call may begin after the close starts — and
// the bridge does not make it optional. What it does do is refuse the call that
// races the start, and that refusal is load-bearing rather than courteous: a
// call admitted after the closer has begun increments the in-flight count behind
// a wait that has already read it, and the wait can then be left waiting on a
// count that reaches zero only when the late caller happens to leave.
//
// The seam is what makes the window wide enough to aim at: a held poller keeps
// the close inside its wait for as long as this scenario wants it there.
std::string LateCallOnce() {
cultmesh_quic_debug_hold_calls(1);
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr) {
cultmesh_quic_debug_hold_calls(0);
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
}
std::thread poller([runtime] {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
});
const auto counted_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
while (cultmesh_quic_debug_peak_calls() < 1 &&
std::chrono::steady_clock::now() < counted_deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
std::thread closer([runtime] { cultmesh_quic_runtime_close(runtime); });
// The close has begun its wait once it has read the count, and the held
// poller is what keeps it there.
const auto started_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
while (cultmesh_quic_debug_calls_at_close() < 0 &&
std::chrono::steady_clock::now() < started_deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
// Two calls, from a thread of their own so that a bridge which admits them
// cannot park this one: one that does not block, and then the blocking
// crossing. That order, because a bridge that admits the blocking one parks
// it inside the library until the hold is released, and a call made after
// that would be racing the teardown rather than the wait.
std::atomic<int32_t> late_poll{0};
std::atomic<int32_t> late_error{0};
std::thread late([runtime, &late_poll, &late_error] {
std::array<char, 256> message{};
late_error.store(cultmesh_quic_last_error(runtime, message.data(),
static_cast<int32_t>(message.size())));
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
late_poll.store(cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required));
});
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
cultmesh_quic_debug_hold_calls(0);
late.join();
closer.join();
poller.join();
if (at_close < 1)
return "the close did not begin its wait with the poller counted inside, so nothing held it "
"open for a call to race";
if (late_poll.load() != -1)
return "cultmesh_quic_next_event returned " + std::to_string(late_poll.load()) +
" to a call made during the close, not the -1 that refuses it";
if (late_error.load() != -1)
return "cultmesh_quic_last_error returned " + std::to_string(late_error.load()) +
" to a call made during the close, not the -1 that refuses it";
return {};
}
// How long `holdtimeout`'s poll asks for, and how long the hold stays armed
// under it. The gap between them is the whole measurement: a poll the hold
// parks leaves when the hold is released, so its elapsed time lands at the
// second number instead of the first.
constexpr int32_t kHeldTimeoutPollMs = 150;
constexpr int kHoldArmedMs = 800;
// The seam's own rule, and the only scenario that can see it: the hold parks a
// call the wait woke, and not one whose own timeout expired.
//
// Everything else that arms the hold parks a woken call, so the guard governs
// nothing either of them observes — the timeout scenario never arms the hold,
// and the hold scenarios never let a timeout expire, so nothing was ever in
// both states at once and the guard could be deleted with every scenario still
// green. This puts one call in both states: the hold is armed, and the runtime
// is idle, so the wait ends on the timeout and the call is on its way out.
//
// It matters because a scenario that parked such a call would then report the
// bridge keeping a host call inside the library when what kept it was the
// fixture — the quiesce numbers `holdclose` asserts would be the seam's, not
// the bridge's.
std::string HoldTimeoutOnce() {
cultmesh_quic_debug_hold_calls(1);
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr) {
cultmesh_quic_debug_hold_calls(0);
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
}
// On a thread of its own, because a parked call does not come back until the
// release below and this thread is what releases it.
TimedPoll poll{};
std::thread poller([runtime, &poll] { poll = PollFor(runtime, kHeldTimeoutPollMs); });
std::this_thread::sleep_for(std::chrono::milliseconds(kHoldArmedMs));
cultmesh_quic_debug_hold_calls(0);
poller.join();
cultmesh_quic_runtime_close(runtime);
if (poll.result != 0)
return "a poll ended by its own timeout returned " + std::to_string(poll.result) +
", not the 0 an idle runtime owes it";
if (poll.elapsed_ms < kHeldTimeoutPollMs - kEarlyToleranceMs)
return "a poll asking for " + std::to_string(kHeldTimeoutPollMs) + " ms returned after " +
std::to_string(poll.elapsed_ms) + " ms, so the fixture never let its timeout run out";
if (poll.elapsed_ms > kHeldTimeoutPollMs + LateToleranceMs(kHeldTimeoutPollMs))
return "a poll asking for " + std::to_string(kHeldTimeoutPollMs) + " ms returned after " +
std::to_string(poll.elapsed_ms) + " ms: the hold parked a call its own timeout had "
"already ended, and it left when the hold did";
return {};
}
int HoldTimeout(int iterations) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = HoldTimeoutOnce();
if (!failure.empty())
return Fail("holdtimeout iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("holdtimeout %dx: ok\n", iterations);
return 0;
}
int LateCall(int iterations) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = LateCallOnce();
if (!failure.empty())
return Fail("latecall iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("latecall %dx: ok\n", iterations);
return 0;
}
int HoldClose(int iterations, int pollers) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = HoldCloseOnce(pollers);
if (!failure.empty())
return Fail("holdclose iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("holdclose %dx%d: ok\n", iterations, pollers);
return 0;
}
#endif // CULTMESH_QUIC_DEBUG_ASSERTS
int CloseRace(int iterations, int pollers) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = CloseRaceOnce(pollers);
if (!failure.empty())
return Fail("closerace iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("closerace %dx%d: ok\n", iterations, pollers);
return 0;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr,
"usage: %s closerace|holdclose|holdtimeout|polltimeout|payloadfit|latecall "
"[iterations] [pollers]\n",
argv[0]);
return 2;
}
const std::string scenario = argv[1];
const int iterations = argc > 2 ? std::atoi(argv[2]) : 20;
const int pollers = argc > 3 ? std::atoi(argv[3]) : 256;
if (iterations <= 0 || pollers <= 0) {
std::fprintf(stderr, "%s needs a positive iteration and poller count\n", scenario.c_str());
return 2;
}
if (scenario == "closerace") return CloseRace(iterations, pollers);
if (scenario == "polltimeout") return PollTimeout(iterations);
if (scenario == "payloadfit") return PayloadFit(iterations);
if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout") {
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
if (scenario == "latecall") return LateCall(iterations);
if (scenario == "holdtimeout") return HoldTimeout(iterations);
return HoldClose(iterations, pollers);
#else
std::fprintf(stderr,
"%s needs the development seam: configure with "
"-DCULTMESH_QUIC_DEBUG_ASSERTS=ON\n", scenario.c_str());
return 2;
#endif
}
std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str());
return 2;
}

View file

@ -31,8 +31,7 @@ shader semantics.
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 +58,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));

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()
{

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")
@ -4317,6 +4317,14 @@ class CultMeshTests(unittest.TestCase):
{"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
expected_schema_version="cultnet.error.v0",
)
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this v0-only Mesh server
# refuses a cultnet.selection.v1 carrier message through the same delegating path
# (_handle_connection_message -> handle_message) as any other unsupported schema, not a
# silent empty response.
unsupported_v1 = raw_client.request(
{"schemaVersion": "cultnet.snapshot_request.v1", "messageId": "bad-v1-selection"},
expected_schema_version="cultnet.error.v0",
)
finally:
server.stop()
@ -4328,6 +4336,9 @@ class CultMeshTests(unittest.TestCase):
self.assertEqual(malformed_put["code"], "malformed_document_put")
self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1")
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
peer = CultMeshPeerCard(

View file

@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
} if received == &message_id => {
snapshot_response = Some(message);
}
cultnet_rs::CultNetMessage::Error { error } => {
cultnet_rs::CultNetMessage::Error { error, .. } => {
let _ = client.disconnect(b"snapshot-error".to_vec());
anyhow::bail!("CultMesh RUDP snapshot failed: {error}");
}

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

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
from collections import defaultdict
import json
import re
import selectors
import signal
import socket
@ -55,19 +56,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"
@ -384,9 +385,38 @@ def handle_server_message(state: PeerState, message: dict[str, Any], subscriptio
return [state.verse_catalog.create_response(message)]
if schema_version == PEER_EXCHANGE_REQUEST:
return [state.peer_catalog.create_response(message)]
if is_unsupported_cultnet_version(schema_version):
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): this peer speaks
# cultnet.selection.v1's carrier messages (snapshot_request.v1, database_subscribe.v1, and
# any later vN) not at all - it is v0-only. A non-v0 message is refused loudly, not answered
# with a silent empty list. The prior check only matched ".v1" exactly, so v2+ fell through
# to the silent empty return below instead of being refused (R-L).
return [unsupported_schema_version_error(message)]
return []
_CULTNET_SCHEMA_VERSION_SUFFIX = re.compile(r"^cultnet\..*\.v(\d+)$")
def is_unsupported_cultnet_version(schema_version: Any) -> bool:
"""True for any cultnet.*.vN with N > 0 - every version this v0-only peer does not speak (R-2/R-L)."""
if not isinstance(schema_version, str):
return False
match = _CULTNET_SCHEMA_VERSION_SUFFIX.match(schema_version)
return match is not None and int(match.group(1)) > 0
def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]:
schema_version = message.get("schemaVersion")
return {
"schemaVersion": "cultnet.error.v0",
"messageId": str(message.get("messageId") or ""),
"error": f"Unsupported CultNet message schema: {schema_version!r}.",
"code": "unsupported_schema_version",
"details": {"schemaVersion": schema_version},
}
def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]:
subscription_id = str(message.get("subscriptionId") or message.get("messageId") or "")
if not subscription_id:

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

@ -22,7 +22,12 @@ from cultnet_py.compare_csharp import (
_median_result,
_parity_status,
)
from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response
from cultnet_py.interop_peer import (
append_shard_log_put,
build_state,
handle_server_message,
raw_snapshot_response,
)
from cultnet_py import (
compute_simulation_claim_hash,
CultNetDatabaseChange,
@ -1677,7 +1682,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 +1691,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 +1907,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 = {
@ -1948,6 +1953,31 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(response["shardLogSequence"], 1)
self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
def test_python_interop_peer_refuses_v1_selection_messages(self) -> None:
# R-2, owed by Cut 1 (docs/cultnet-selection-cut.md, section 9): a v0-only peer refuses any
# non-v0 message loudly - cultnet.error.v0 { code: "unsupported_schema_version" } - rather than
# silently answering with an empty list. handle_server_message never touches `state` before
# this refusal fires, so no PeerState fixture is needed here. R-L: the original check matched
# only ".v1" exactly, so v2+ silently fell through to an empty list instead of being refused -
# this covers v2 and v10 (double-digit) too, not only the exact v1 spelling.
for schema_version in (
"cultnet.snapshot_request.v1",
"cultnet.database_subscribe.v1",
"cultnet.snapshot_request.v2",
"cultnet.database_subscribe.v2",
"cultnet.snapshot_request.v10",
):
with self.subTest(schema_version=schema_version):
responses = handle_server_message(
None, # type: ignore[arg-type]
{"schemaVersion": schema_version, "messageId": "v1-refused"},
{},
)
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0")
self.assertEqual(responses[0]["code"], "unsupported_schema_version")
self.assertEqual(responses[0]["messageId"], "v1-refused")
def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
from cultnet_py import CultNetSimulationConsensus

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

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