Compare commits

..
Author SHA1 Message Date
dc5abedffc Merge pull request 'C#: SubmitDocumentAsync and the shard write forwarder complete on the result frame' (#58) from libby/cs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 7m52s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 13:24:39 +00:00
Libby
09e6a528b8 C#: SubmitDocumentAsync and the shard write forwarder complete on the result frame
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
CultNetWriteCompletion.SendAndAwaitResultAsync registers a refusal handler before
the send, then waits a result window (default 500 ms; CultMeshClientOptions.
SubmitResultWindow, CultNetSchemaWriteForwarderOptions.ResultWindow). A
cultnet.error.v0 inside it rejects with the new CultNetWriteRefusedException
(Code: write_not_authorized, message_invalid, ...) whatever its order against the
send or transport ack; a send returning no longer completes the write. The
forwarder's refusal now throws on the same path as an unreachable primary, so
CultNetDatabaseServer.TryForwardPutAsync/DeleteAsync no longer report a refused
forward as done. Raw send primitives (C# schema client/session, Python, Kotlin)
carry the doc line: fire-and-forget, an ack or a return is not admission, read the
result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:24:27 +00:00
1b3b625474 Merge pull request 'cultmesh-rs: single and batch publish complete on the result frame, not the transport ack' (#57) from libby/rs-client-ack-finding into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:18:41 +00:00
Libby
675a6494c1 cultmesh-rs: single and batch publish complete on the result frame, not the transport ack
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m12s
One loop now serves both: every put acknowledged, then a refusal frame (typed
cultnet.error.v0) fails the call in either order against the ack, and a success
frame or the end of the new result_window option (default 500 ms) completes it.
The batch no longer waits on flush_reliable alone, so it cannot hide a refusal.
Raw send primitives (CultNetRudpSocketTransportConnection::send/send_reliable/
flush_reliable, tcp_framed send) carry the doc line: fire-and-forget, an ack or
a return is not admission, read the result frame.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:18:40 +00:00
Libby
3c68d7d19c Merge remote-tracking branch 'origin/main' into libby/rs-client-ack-finding 2026-11-08 13:15:17 +00:00
3162e2ea66 Merge pull request 'cultnet-ts: the Odin presence publisher completes on the result frame, not the ack' (#56) from libby/ts-client-ack-finding-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 13:15:08 +00:00
Libby
531fce567d cultnet-ts test: cancel the Odin stub's delayed send when the test ends
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 57s
The stub's 120 ms second packet fired after peer.close() and threw
ERR_SOCKET_DGRAM_NOT_RUNNING as an uncaught exception, failing whichever
publisher test was running when the timer landed (load-dependent). Timers are
now cleared in the test's after hook instead of swallowing the error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:14:49 +00:00
Libby
0b326fa5ee WIP cultnet-ts: Odin presence publisher completes on the result frame, not the ack (passes alone; two publisher tests fail inside the parallel full run, unexplained)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:36:23 +00:00
Libby
cdbe5889e0 merge main 2026-11-08 11:35:00 +00:00
ff1c92d2fe Merge pull request 'cultmesh-ts: a put completes on its result frame, not on the transport ack' (#55) from libby/ts-client-ack-finding-cultmesh into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 31s
2026-11-08 11:34:56 +00:00
Libby
7082c9488b cultmesh-ts: a put completes on its result frame, not on the transport ack
publishRudpDocumentOnce resolved when peer.flush returned (ack only) and read no frame, so a
refused put resolved. It now rejects with CultMeshRudpWriteRefusedError (the peer's typed
cultnet.error.v0: write_not_authorized, message_invalid) whether the refusal arrives before the ack,
after it, or within resultWindowMs (default 500 ms) after it; a success reply ends the wait early.
publishRudpDocumentAndWaitForReceipt rejects with the same typed error instead of timing out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:34:55 +00:00
Libby
dc7eec7e6e merge main 2026-11-08 11:33:16 +00:00
d4e4a2c085 Merge pull request 'GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack' (#54) from libby/cs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:21 +00:00
8cfc41d665 Merge pull request 'cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived' (#53) from libby/py-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:18 +00:00
6f2092171f Merge pull request 'cultmesh-rs test: a publish whose refusal arrives before the ack is refused' (#52) from libby/rs-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:15 +00:00
de1d6b3444 Merge pull request 'cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first' (#51) from libby/ts-client-ack-is-not-admission-cultnet into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:12 +00:00
822d660627 Merge pull request 'cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused' (#50) from libby/ts-client-ack-is-not-admission into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:30:09 +00:00
Libby
c1d24146db GameCult.Networking test: the RUDP schema client delivers a refused put's result frame whatever the order of the ack
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:29:50 +00:00
Libby
c6da8fc54a cultmesh-py test: a put the server refused is reported refused after its reliable ack arrived
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m26s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:28:23 +00:00
Libby
9e734447f6 cultmesh-rs test (FAILS, findings): publish completes on the ack when the refusal follows it; the batch publish completes on the ack whatever the refusal
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m32s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:53 +00:00
Libby
f53930bc13 cultmesh-rs test: a publish whose refusal arrives before the ack is refused
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m16s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:48 +00:00
Libby
dfd090dc55 cultmesh-ts test (FAILS, a finding): publishRudpDocumentOnce completes on the ack of a refused put
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:07 +00:00
Libby
4f90ea8156 cultmesh-ts test: publishRudpDocumentAndWaitForReceipt reports a refused put as refused, whether the ack comes before or after the refusal
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:27:06 +00:00
Libby
7766348c75 cultnet-ts test (FAILS, a finding): the Odin presence publisher completes on the ack when the refusal follows it
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
Libby
08cf3b34b5 cultnet-ts test: the Odin presence publisher does not complete on the ack when the refusal arrives first
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m59s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:26:22 +00:00
7486f0a326 Merge pull request 'cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged' (#48) from libby/rs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:21:28 +00:00
Libby
b2077fa2bf cultmesh-rs: a Pong in the capped grace acknowledges nothing; the grace deadline saturates
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m57s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m41s
A Ping in the 2 s grace after the capped frame was answered with a Pong built from the
session's ack state, which includes the capped frame. Every reply while the session is
ending now zeroes its ack fields. The deadline no longer wraps at the end of the clock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:21:08 +00:00
3915a4e5f5 Merge pull request 'GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session' (#49) from libby/cs-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 11:10:54 +00:00
Libby
0b1a468ddd GameCult.Networking: resend the capped frame's answer until acknowledged, then end the session
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 8m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m13s
The schema server answered the 8th undecodable frame and ended the peer's session in the same
call; ending clears the session's pending reliable packets, so a lost datagram was never
resent. The session now stays until the peer has acknowledged every answer (a lost one is
resent by PollResends) or a 2 s grace runs out, serves nothing after the capped frame
meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:10:48 +00:00
Libby
03141bc93b cultmesh-rs: send the capped frame's answer reliably and end the session once it is acknowledged
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
The capped (8th) refusal went out unreliable and unordered with the goodbye at once, so a
lost datagram left the peer without it. It is now queued like any response and resent until
the peer acknowledges it; the session ends then (or after a 2 s grace) and serves nothing
after the capped frame meanwhile. The answer, its resends and the poll that carried the
capped frame acknowledge nothing, as every refusal. A reliable queue that cannot take the
answer ends the session at once with the unreliable refusal, as before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 11:04:22 +00:00
337168812e Merge pull request 'cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session' (#47) from libby/ts-capped-answer-reliable into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 10:59:27 +00:00
Libby
9e5edeca52 cultmesh-ts: resend the capped frame's answer until acknowledged, then end the session
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m6s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
endCappedSession deleted the session right after the answer was sent, so the resend
timer never saw it and a lost datagram left the peer without the answer. The session
now stays until the peer has acknowledged every reply (a lost one is resent) or a 2 s
grace runs out, serves nothing after the capped frame meanwhile, and then sends the goodbye.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 10:59:23 +00:00
fe73795d89 Merge pull request 'cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals' (#45) from libby/py-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m3s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 57s
2026-11-08 09:54:41 +00:00
Libby
bf81695537 cultmesh-py: answer the capped frame reliably and end the session once the answer is acknowledged
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m17s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 52s
The 8th refusal was sent unreliable and unordered with only the first response,
then the goodbye at once, so a lost datagram left the peer without it. All answers
now go the normal reliable path; the session ends when the peer has acknowledged
them (a lost one is resent) or after a 2 s grace, and serves nothing after the
capped frame meanwhile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:54:37 +00:00
b1d6ec098d Merge pull request 'cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes' (#44) from libby/ts-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:52:26 +00:00
Libby
23c7ce3738 cultmesh-ts: count and answer a frame that decodes but is no message the parser takes
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m9s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m17s
A map with an unknown schemaVersion was rethrown: unanswered, uncounted toward the
session cap. It is now answered message_invalid, reported, and counted like any other
unreadable frame, so the cap holds for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:52:22 +00:00
51d2d0cf46 Merge pull request 'GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row' (#46) from libby/cs-refused-frames-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:47:22 +00:00
Libby
89bbb277bd GameCult.Networking: answer an undecodable RUDP frame message_invalid and end the session at 8 in a row
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m10s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m20s
Heimdall's parity ruling. The schema server ended a peer's session on the first
frame it could not decode. It now answers message_invalid (field "message") and
keeps the session; RudpCultNetSchemaServer.MaxConsecutiveRefusedFrames (8)
consecutive undecodable frames end it after the last is answered, and any
decoded frame resets the run. Handler faults still end the session at once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:47:08 +00:00
Libby
b4979cb65b cultmesh-py: answer an unreadable RUDP frame and end the session after 8 consecutive refusals
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. A frame that is no msgpack map used to be dropped
silently; it is now answered message_invalid (field "message") and the session
kept. MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive unreadable frames, or
consecutive deletes the write authority refuses, end the session after the
capped frame is answered. The authority's decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:43:41 +00:00
Libby
621e05d552 cultmesh-ts: end a RUDP session after 8 consecutive unreadable frames or authority-refused deletes
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
Heimdall's parity ruling. The refusal is still answered and the session kept
below the cap; the frame that reaches CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES
is answered too, then the session ends. The write-authority decision is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:41:18 +00:00
4828c41646 Merge pull request 'cultmesh-rs: keep the session through an unreadable frame or refused delete; end at 8 in a row' (#43) from libby/keep-session-invalid-cap into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-08 09:36:55 +00:00
Libby
a0d9ed0cc5 cultmesh-rs: keep the session through an unreadable frame or refused delete; end it at 8 in a row
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 3m28s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 56s
Heimdall's parity ruling: the refusal is answered on the live session, and
MAX_CONSECUTIVE_REFUSED_FRAMES (8) consecutive undecodable frames (reset by
any decoded frame) or refused deletes (reset by any decoded non-delete) end
it. The sink gate is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:36:41 +00:00
fb705cbc1d Merge pull request 'cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors' (#42) from libby/rs-rudp-typed-refusals into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:33:41 +00:00
Libby
34be64789e cultmesh-rs: RUDP document server refuses unreadable frames and deletes with typed errors
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m40s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
An undecodable schema frame was dropped and a DocumentDelete ignored, both
silently. Now the first is refused message_invalid (details.field "message")
and the second write_not_authorized naming the record, each with a fixed
reason, ending the peer's session as the other refusals do. The sink gate is
unchanged. Both bytes are pinned against the shared error-vectors.json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:33:27 +00:00
a95b43d48f Merge pull request 'cultnet error contract: one message_invalid code replaces malformed_document_put/delete' (#41) from libby/error-message-invalid into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 4m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-08 09:17:52 +00:00
Libby
a558f3ad3b cultmesh-py: pin details.field on the message_invalid refusals of a malformed or unstorable put
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 4m43s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00
Libby
8f71bff69c cultnet error contract: one message_invalid code replaces malformed_document_put/delete
Q6-A of docs/cultnet-error-contract-cut.md. message_invalid joins the closed
cultnet.error.v0 code enum (both schema copies), with details.field naming the
request field at fault ("message" when the whole frame cannot be read).
C# ForMessageInvalid, Rust CultNetErrorCode::MessageInvalid, TS
cultNetMessageInvalidError and Python message_invalid_error all write the same
captured bytes (error-vectors.json plus the C# and Rust cases). cultmesh-py and
cultmesh-ts refuse malformed puts and deletes with it; the C# subscription
servers use it for the missing subscriptionId.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:14:12 +00:00
fd119ea36c Merge pull request 'cultmesh-ts: write authority decide timeout counts as a typed refusal' (#40) from libby/ts-decide-timeout into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 09:13:11 +00:00
Libby
386f036849 cultmesh-ts: test that a grant arriving after the decide timeout is refused and never reaches the handler
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 4m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:13:08 +00:00
Libby
43abf9762f cultmesh-ts: refuse an unusable writeAuthorityTimeoutMs at construction
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m2s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Review finding: NaN, Infinity or >2^31-1 becomes a 1 ms Node timer and would
refuse every write that is not instant; 0 and negatives were silently clamped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:59:25 +00:00
Libby
3f150799f0 cultmesh-ts: write authority decide timeout counts as a typed refusal
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m41s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 31s
A never-settling decide() held up the later frames of its session. The
server now races it against writeAuthorityTimeoutMs (default 5000) and
refuses with write_not_authorized on expiry. The granted-path test now
asserts the canonical schemaId the authority saw.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:50:07 +00:00
e8b2e44496 Merge pull request 'semver-policy: exact commands for reading the workflow at the tagged sha; evidence for the cultcache-ts-v0.15.0 run' (#39) from eureka/tag-sha-workflow-step into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m42s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 21s
2026-11-08 03:52:59 +00:00
3dd9bde017 semver-policy: exact commands for the before-pushing-a-tag step, and the 0.15.0 run's evidence
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m19s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:51:27 +00:00
cbae9ec6fe Merge pull request 'docs: drop stale cultcache-ts tag-trigger claim from the content map' (#38) from libby/ts-tag-trigger-docs into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-08 03:49:17 +00:00
Libby
6822da6187 semver-policy, content-map: follow review of the cultcache-ts tag-trigger text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:49:16 +00:00
Libby
5155c5b834 content-map: no cultcache-ts tag triggers publish-packages on main
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m47s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 51s
The C5 plan still said the cultcache-ts tag triggers the npm publish; F16 and the workflow say it does not. npm view 404 re-checked 2026-11-08.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:48:36 +00:00
250d616f8f Merge pull request 'cultmesh-ts: write authority for the RUDP document server' (#37) from libby/cultmesh-ts-write-authority into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m14s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 26s
2026-11-07 20:21:27 +00:00
Libby
3f4139549e cultmesh-ts: CHANGELOG states the refusal size bound
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:21:26 +00:00
Libby
ada30be84e cultmesh-ts: gate before shard refusal, bound the refusal, harden tests
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m2s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
A put with wrong-typed shard fields is judged with null shard fields first, then
refused as malformed once granted. The refusal's record key and reason are
clipped so it fits maxPayloadBytes. The allocation test watches transient
allocations; catch-path and delete tests assert ids, operation, reply count,
onError and handlers. README/CHANGELOG no longer claim the schema is resolved
after the gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:13:49 +00:00
Libby
1689b62597 cultmesh-ts: gate unreadable writes, malformed refusals, payload tests
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m22s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m5s
A frame that fails to decode or parse is judged as the write it looks like, so
an ungranted peer gets write_not_authorized instead of silence; a granted peer
gets onError and a malformed_document_put/delete refusal. Adds raw-frame tests
(int, 2**70, oversized claimed length, non-decoding, non-map document) and
the timing residual note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:57:42 +00:00
Libby
1bc05d7b06 cultmesh-ts: document server asks a write authority before applying a peer put or delete
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Deny by default with a grant-all opt-in; the gate runs before the put is sized,
resolved or decoded; refusal is the typed write_not_authorized. Adds
onDocumentDeleteRaw, refuses wrong-typed shard fields before the gate, grants
all writes in the existing server tests, and ports the cultmesh-py
remote-write-authority tests (no forwarding).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:52:42 +00:00
Libby
474e27fcb3 WIP B 2026-11-07 19:36:40 +00:00
f0731c6b91 Merge pull request 'cultnet-ts: typed write_not_authorized factory and canonical schema id' (#36) from libby/cultnet-ts-write-refusal into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 36s
2026-11-07 19:36:36 +00:00
Libby
2016ad7457 cultnet-ts: write_not_authorized blank-reason check is C# IsNullOrWhiteSpace
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Review: trim() differs from string.IsNullOrWhiteSpace on U+0085 and U+FEFF; use the
package's isNullOrWhiteSpaceCSharp and pin both in the test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:36:32 +00:00
Libby
d0c0156874 cultnet-ts: typed write_not_authorized factory and canonical schema id
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
cultNetWriteNotAuthorizedError(recordKey, reason?) writes the C# ForRemoteWriteRefused
bytes (pinned to error-vectors.json). CultNetDocumentRegistry.canonicalSchemaId resolves
the id a put or delete writes under, from the id alone, for a server's write authority.
No behaviour change for existing callers; the registry already had a delete apply.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:35:21 +00:00
10c1f4dd00 Merge pull request 'cultmesh-rs: a sink's refusal of a put is a typed write_not_authorized' (#35) from libby/cultmesh-rs-typed-refusal into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 19:31:48 +00:00
Libby
74ccfaf67a cultmesh-rs: a sink's refusal of a put is a typed write_not_authorized
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m48s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 55s
The RUDP document server sent a host sink's refusal as an untyped Error (code None).
It now sends write_not_authorized with details naming the record, as C#, TS and
Python do; the text stays the fixed variant text, never the sink's. The rejection
carries the put's record_key. Other rejections stay untyped. Unit test pins the
bytes to the error-vectors.json write_not_authorized vector; integration tests
check code/details on the wire and that a refused put reaches no sink.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:30:54 +00:00
608ddb94f7 Merge pull request 'cultmesh-py: bind the TCP+UDP pair for port 0 without the race' (#34) from libby/cultmesh-py-port-race into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 19:28:51 +00:00
Libby
cff68d5528 cultmesh-py: bind test proves only the UDP number was taken
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:28:50 +00:00
Libby
e0f5e13a1b cultmesh-py: bind the TCP+UDP pair for port 0 without the race
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 4m46s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
CultMeshLocalServer.start bound TCP to port 0 and then bound UDP to whatever number
the kernel picked, failing with EADDRINUSE whenever another process held that
number for UDP (this host has hundreds of UDP listeners), and left the accept
thread running on failure. Port 0 now retries with a fresh pair (bounded); both
sockets bind before any thread starts; an explicit port still fails honestly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:25:38 +00:00
e2dc04e724 Merge pull request 'cultmesh-py: peer writes need a write authority (default-deny)' (#33) from libby/cultmesh-py-remote-write-authority into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 19:19:27 +00:00
Libby
87c4ac366d cultmesh-py: refuse wrong-typed shard fields before the write gate
A non-string shardId or non-int shardEpoch (bool is not int) on a put or delete
is a malformed_document_put/delete before the authority is asked, so a
shard-scoped authority cannot be bypassed by a shard the store would file under
str(shardId). The authority receives the fields as sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:19:26 +00:00
Libby
f074c3d45f cultmesh-py: document why schema resolution precedes the write gate
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 30s
Comment at the gate and a README residual; no behaviour change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:17:30 +00:00
Libby
61a1d872b3 cultmesh-py: gate runs before an ungranted peer's payload is touched
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
raw_put_target resolves only a put of the wire shape (string key, bytes payload),
so an int payload is never converted (bytes(10**9)) before the authority speaks.
Shard fields of the wrong type reach the authority as None. Tests: huge payload
refused without allocating, RUDP peer path (endpoint and transport), shard types.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:14:12 +00:00
Libby
2947e15088 cultmesh-py: peer writes need a write authority (default-deny)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 20s
Finishes the remote-write gate: exports, daemon --allow-peer-writes, the gate is
total (an ungranted peer learns nothing from malformed/unowned-put replies),
existing server tests grant explicitly, 12 tests port RemoteWriteAuthorityTests
(forwarding has no Python counterpart), refusal bytes pinned to error-vectors.json.
README documents the breaking change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:07:32 +00:00
Libby
7a721204ae WIP cultmesh-py: remote write authority gate (not finished, not for merge)
Adds write_authority.py, node.raw_put_target/raw_delete_target, the gate in
_handle_raw_put/_handle_raw_delete with the peer passed in, and serve_node/serve params.
Missing: __init__ exports and imports in facade.py/session.py, daemon --allow-peer-writes,
tests, README and Breaking note. Untested.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:59:47 +00:00
d00222761c Merge pull request 'Docs: cultcache-ts publishing is off; tag runs use the tagged workflow' (#32) from libby/publish-docs-cleanup into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 5m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 36s
2026-11-07 16:53:04 +00:00
Libby
bf5a09bd04 publish-packages: say that cultcache-ts publishing is off; keep the tag pin readable
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m5s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 25s
The header comment, semver-policy and F16 of the content map still listed cultcache-ts-v*
as a trigger. They now say publishing is deliberately off and that a tag run uses the
workflow at the tagged commit (cultcache-ts-v0.15.0 sits on 7d292085, before the guard).
semver-policy gains a "Before pushing a tag" note. The guard's comment moves above `tags:`:
inside the list it broke the three `workflow:` tests of check-changelog-semver.test.mjs,
which read `tags:` followed directly by its items.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:53:03 +00:00
e75285c067 Merge pull request 'publish-packages: cultcache-ts-v* tags no longer trigger publish' (#31) from libby/disable-cultcache-ts-tag-publish into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:38:12 +00:00
Libby
8569825630 publish-packages: stop cultcache-ts-v* tags from triggering the npm publish
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m38s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
Publishing @gamecult/cultcache-ts is deliberately disabled until ruled on, and whether
NPM_TOKEN is set is unknown. The other tag patterns and workflow_dispatch are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:38:11 +00:00
7d29208539 Merge pull request 'cultcache-ts 0.15.0: fold 0.14.1, 0.14.2 and Unreleased; raise consumer ranges' (#30) from libby/cultcache-ts-0.15.0 into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
Publish packages / cultcache-py to PyPI (push) Has been skipped
Publish packages / cultmesh-py to PyPI (push) Has been skipped
Publish packages / cultnet-py to PyPI (push) Has been skipped
Publish packages / @gamecult/cultcache-ts to npm (push) Failing after 9m25s
2026-11-07 16:34:31 +00:00
Libby
7ac9e60bdc cultcache-ts 0.15.0: fold the untagged 0.14.1/0.14.2 and Unreleased into one entry
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m57s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
0.14.1 and 0.14.2 were never tagged; cultcache-ts-v0.14.0 is the last release. The
Unreleased exports (SchemaConflictError, schemaIdentityOf, the document payload codec)
are in no tagged release, which is what failed the declared-ranges check. Package and
lockfile go to 0.15.0 and every consumer range (cultnet-ts, cultmesh-ts, cultmesh-browser)
moves to ^0.15.0, since a 0.x caret does not admit a new minor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:31:04 +00:00
da515b0f5e Merge pull request 'Fix build:ts: one text-only error factory in cultnet-ts (6 TS2345 in cultmesh-ts)' (#29) from libby/cultmesh-ts-error-message-sites into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 16:27:31 +00:00
Libby
d0fcd3e36e cultnet-ts, cultmesh-ts: one factory for the text-only error message
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m15s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
PR #20 made routingHint, code and details required on CultNetErrorMessage; six call sites in
cultmesh-ts still built the message without them, so build:ts failed with TS2345. cultnet-ts
now owns cultNetTextError(error), the legacy-union decode uses it too, and the call sites
and two test expectations call it. The types are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:27:24 +00:00
8477d8dc74 Merge pull request 'npm: scope cultnet-ts, cultmesh-ts, cultmesh-browser under @gamecult' (#28) from libby/scope-npm-packages into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 16:25:15 +00:00
Libby
286a9af389 npm: scope cultnet-ts, cultmesh-ts and cultmesh-browser under @gamecult
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m49s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
Unscoped npm names are unclaimed and open to dependency confusion; cultcache-ts was
already @gamecult/cultcache-ts. package.json names, intra-repo dependencies, package-lock,
import specifiers (src, tests, samples, docs/start, first-call docs test), the workspace
list in run-typescript-workspaces, the verify-eve module map and package READMEs now use
the scoped names. Directory names, workspace paths in CI and release tag prefixes are
unchanged. Historical cut docs and past CHANGELOG entries keep the old names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:25:10 +00:00
655cb2c909 Merge pull request 'cultcache-rs: pin the one-global rule on load_soa (PR #15 gap)' (#27) from libby/cultcache-rs-load-soa-global-test into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:18:28 +00:00
Libby
08715ac55c cultcache-rs: pin the one-global rule on load_soa
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 4m50s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
load_soa installs a whole table and checks it with refuse_second_globals, but no test
reached that call (PR #15 covered put paths and pull_all). A table holding two records
of a global type is refused with CultCacheSecondGlobal and the prior view survives; a
table with one global loads and replaces the view.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:17:05 +00:00
ec7a009b02 Merge pull request 'cultnet: add unsupported_schema_version to the closed cultnet.error.v0 code set' (#26) from libby/error-code-unsupported-schema-version into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
2026-11-07 16:16:17 +00:00
Libby
d18def46b3 Merge remote-tracking branch 'origin/main' into t26
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m33s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 25s
2026-11-07 12:27:39 +00:00
f1cdbfdfd7 Merge pull request 'cultnet-ts selection/v1 carriers + error code/details' (#20) from libby/cultnet-ts-selection-v1 into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 6m39s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 41s
2026-11-07 12:27:32 +00:00
Libby
fc184b0888 Merge remote-tracking branch 'origin/main' into t20
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 21s
# Conflicts:
#	packages/cultnet-ts/src/contracts.ts
#	packages/cultnet-ts/src/index.ts
2026-11-07 12:26:32 +00:00
1718a50642 Merge pull request 'cultnet-py multi-peer RUDP socket server' (#19) from libby/cultnet-py-multipeer-server into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:26:06 +00:00
Libby
95a4680dfe Merge remote-tracking branch 'origin/main' into t19
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m43s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 46s
2026-11-07 12:25:23 +00:00
33cadb25fa Merge pull request 'cultmesh-py/rs authority route verification' (#18) from libby/cultmesh-route-verification into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:25:15 +00:00
Libby
0c23338204 Merge remote-tracking branch 'origin/main' into t18
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 7m16s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 32s
2026-11-07 12:23:57 +00:00
Libby
0bffcf0f9f cultnet: add unsupported_schema_version to the closed cultnet.error.v0 code set
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Ruling (steward): add the code across C#, Rust, TypeScript and Python, each with a
captured-bytes case. details = { field: "schemaVersion", value: <offered version> }, no routingHint.

- schema (both copies), C# ForUnsupportedSchemaVersion + CultNetErrorCode.UnsupportedSchemaVersion in Rust,
  TS code list, Python CULTNET_ERROR_CODES and schema catalog.
- bytes: error-vectors.json gains unsupported_schema_version (C# serializer output, checked by the
  C# test, the Rust error_message.rs case, and the TS/Python vector tests; the Python builder reproduces them).
- cultnet-py interop_peer and cultmesh-py server now answer with the contract-shaped error (no extra
  messageId, details {field,value}) via messages.unsupported_schema_version_error.
- tests that used unsupported_schema_version as the example of an unknown code now use not_a_code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:23:39 +00:00
0fd9d25527 Merge pull request 'cultnet-py: schema catalog describes cultnet.error.v0 as the contract does' (#17) from libby/cultnet-py-error-catalog into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:13:32 +00:00
530acdac20 Merge pull request 'cultcache-py: single-file store lock, fsync and unique staging; msgpack hard; slot-nil reader' (#16) from libby/cultcache-py-store-lock into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:13:21 +00:00
cf0d93d091 Merge pull request 'cultcache-rs: name, index and global lookups (C# parity)' (#15) from libby/cultcache-rs-lookups into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:12:04 +00:00
d76b4033bf Merge pull request 'cultnet error message parity: TS type + Python builder/decoder, shared C#/Rust vectors' (#14) from libby/cultnet-error-parity into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:11:38 +00:00
778a4032c4 Merge pull request 'docs(start): tested CultNet first-call snippets (C#, TypeScript, Rust, Python)' (#11) from libby/start-cultnet-cells into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:10:56 +00:00
Libby
00345b424b Merge remote-tracking branch 'origin/main' into HEAD
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m11s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 26s
# Conflicts:
#	docs/start/README.md
2026-11-07 12:09:33 +00:00
375d1c5ca1 Merge pull request 'docs/start: two-client .io game recipe (CultCache + CultMesh, TS)' (#10) from libby/recipe-io-game into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:09:16 +00:00
5966b4df91 Merge pull request 'ci: drop the Eve getting-started job (GameCult/Eve no longer resolves)' (#25) from libby/ci-drop-eve-checkout into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:07:13 +00:00
a06798fdec Merge pull request 'cultcache-ts: browser-safe entry that excludes the node-only single-file store' (#24) from libby/cultcache-ts-browser-entry into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 12:06:31 +00:00
Libby
4a407f9464 ci: drop the Eve getting-started job; Eve is deprecated and its repo no longer resolves
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m30s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 22s
cultmesh-portability's getting-started job checked out GameCult/Eve (404 on the forge) and
ran verify-eve-getting-started.ps1 -EveRoot ./Eve, which is Eve end to end. Nothing in it
runs without Eve, so the job goes. reactive-and-resource-lifetime has no Eve dependency
and is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:44:55 +00:00
51803afd02 Merge pull request 'docs: mark samples/eve-browser-network as needing a sibling Eve checkout' (#23) from libby/docs-eve-sample-sibling into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 11:40:50 +00:00
Libby
23279b8e50 cultcache-ts: browser-safe entry that excludes the node-only single-file store
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 1m19s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m36s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 36s
The package root imports node:fs, node:path and koffi through the single-file
store, so esbuild platform=browser fails on it. src/browser.ts is the root
surface minus that store; src/index.ts re-exports it and adds the store, so
the Node entry is unchanged. Exposed as ./browser and as the 'browser' export
condition on the root.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:03 +00:00
Libby
3d7126d036 docs: mark samples/eve-browser-network as needing a sibling Eve checkout
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 56s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 37s
The sample ProjectReferences ../Eve and is not in CultLib.sln (PR #22).
README, getting-started 03/04 and browser-verse-transport now say so where they
tell readers to run it. Pages 01 and 03:73 only link Program.cs as source to
read, which needs nothing, so they are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:33:51 +00:00
c17b330d70 Merge pull request 'sln: drop EveBrowserNetworkSample so dotnet build CultLib.sln succeeds' (#22) from libby/drop-eve-sample into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 11:33:13 +00:00
Libby
7d654cc277 sln: drop EveBrowserNetworkSample so dotnet build CultLib.sln succeeds
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 1m1s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m45s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 41s
The sample ProjectReferences ../../../Eve, which is not checked out beside CultLib,
and the only error in the build was Program.cs CS0234 (GameCult.Eve missing).
The sample stays on disk and is built by scripts/verify-eve-browser-network.mjs
with --eve-root; it is just no longer part of the default solution build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:32:01 +00:00
824a1545fd Merge pull request 'ci: Windows legs run on workflow_dispatch only; Ubuntu gates push and PR' (#21) from libby/ci-ubuntu-gate into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 11:31:09 +00:00
Libby
2897b4bad8 ci: Windows legs run on workflow_dispatch only; Ubuntu gates push and PR
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 58s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 6m37s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Successful in 1m56s
The forge runner is Ubuntu-only (Docker, one job), so Windows jobs queued on
push and pull_request can never run.

- cultnet-interop: the whole job is Windows-only; trigger is now workflow_dispatch.
- cultmesh-portability: both matrices keep windows-latest on workflow_dispatch
  and run ubuntu-latest alone on push and pull_request.
- quic-native: unchanged; Build win32-x64 already runs only on dispatch or a tag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:30:22 +00:00
4f4c65d847 Merge pull request 'cultnet-ts: drop unset optional keys from createDocumentPutMessage' (#13) from libby/cultnet-ts-put-nil-keys into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
2026-11-07 11:26:57 +00:00
7e38bf2bf0 Merge pull request 'heimdall-consent: typed records for the answer collector' (#12) from libby/consent-records into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-07 08:28:31 +00:00
Libby
638e71f427 heimdall-consent: re-pin docs to Heimdall PR #6 aaeb175, fix doc line numbers, mark notice state event and registration-checkbox gap as not modelled
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
No behaviour or vector rows changed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 04:10:44 +00:00
Libby
420eb9e840 heimdall-consent: pin the one version rule in the golden vectors (4294967295 ok; 4294967296 and 01 refused)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Case one-version-rule runs version_ok/version_refused for notice_version, the sample id @N and terms_version; the Rust vector test handles both ops. README cites Heimdall PR #6 d6a8d42 for the rule.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 02:40:57 +00:00
Libby
11933ab65b cultnet-ts: selection/v1 carriers (snapshot request, subscribe, raw response), judged against the reference's bytes
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:39:57 +00:00
Libby
51941fa61b Carrier vectors: drop the derived hasHop member from the reference's expected JSON
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:39:57 +00:00
Libby
f5c2771192 cultnet-py: multi-peer RUDP socket server, with C#-captured transcript parity
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:36:55 +00:00
Libby
1d21a93a2d cultnet-ts: cultnet.error.v0 keeps routingHint, code and details, judged against the reference's bytes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:36:25 +00:00
Libby
497ff201aa cultmesh-py: pin high-S signature acceptance to the .NET reference
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:36:05 +00:00
Libby
280ec74c9a cultmesh-rs: port CultMesh authority route verification
ECDSA P-256 route certificate and session proof verification ported from
CultMeshAuthorityProof.cs using the p256 crate (new dependency, pure Rust,
no default features), judged against the shared contracts/cultmesh vectors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:36:05 +00:00
Libby
7b62cf4ddf Capture the reference's selection/v1 carrier and error code/details bytes as interop vectors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:35:54 +00:00
Libby
a36a60b106 cultmesh-py: port CultMesh authority route verification
ECDSA P-256 route certificate and session proof verification ported from
CultMeshAuthorityProof.cs, with typed refusals, judged against the shared
contracts/cultmesh authority route vectors (C# written and TS signed).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 01:34:22 +00:00
Libby
af0b1b6817 heimdall-consent: one version rule for every version field (positive, no leading zeros, <= 4294967295)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
check_version is the single rule; the sample id's @N and MemberTerms.terms_version both call it, so
terms_version "2026-11" is now refused and the golden vector carries 4294967295 (the harness now
validates the terms record). notice_version stays u32 and validate() refuses 0.

Tests: boundary table (1, 4294967295 accepted; 0, 4294967296, 4294967297, 01, +1, -1, empty, 2026-11
refused) across all three; each rule checked by mutation (u64 wrap, leading-zero, sign, terms unchecked,
returned value, empty sample, zero in both records) and killed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 00:32:09 +00:00
Libby
27d7e48cd6 heimdall-consent: pin key(), MemberTerms read-back, acceptance member/experiment, scope rule; refuse leading-zero versions
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Answers Heimdall's review of PR #12 (review 4): tests for the exact U+001F joined key and the empty and
separator refusals; an expect_terms vector op (newcomer case defends the MemberTerms pair) and
expect_no_terms; a mismatched member or experiment acceptance is never training data; the three scope
conditions kept, with a test of the rule over every scope pair; sample versions with a leading zero
are refused (README notes Heimdall may rule normalise instead).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:53:13 +00:00
Libby
81aeb60c57 cultnet-py: schema catalog describes cultnet.error.v0 as the contract does
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Required is the contract's five keys (schemaVersion, error, routingHint, code, details), code is the
closed enum (or null), details and routingHint are object-or-null, and the catalog has no messageId
property, as cultnet.error.schema.json is closed. The test compares required, property names and the
code enum to the contract file instead of pinning the stale two-key form.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:51:14 +00:00
Libby
00ecdcf9e6 cultcache-py: read the slot-nil vector; changelog for the store lock and msgpack
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
tests/vectors/slot-nil was read by TS only. The Python reader accepts the same 15 rows
and refuses the same 2 as the C# reference and rewrites the reference's bytes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:50:10 +00:00
Libby
b2434fc7fc cultcache-py: single-file store takes the cross-process lock, stages uniquely and fsyncs
push, push_all and delete hold <path>.lock (flock; LockFileEx over the whole file on
Windows, as cultcache-ts store-file.ts) across the read and the replace. The staged file
is <path>.tmp-<pid>-<uuid>, fsynced before os.replace, with a directory fsync after off
Windows; a failed write removes its staged file and leaves the store.

Tests fail when the rule breaks: a write waits while a flock holder (Rust's lock) and the
TypeScript runtime's withStoreWriteLock hold the lock, three processes lose no writes,
fsync precedes replace, staged names differ, a failed replace leaves no staged file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:49:32 +00:00
Libby
9548d51a71 cultcache-py: msgpack is a hard dependency; drop the optional-import fallbacks
pyproject (and cultnet-py) already declare msgpack as required; the code treated it
as optional behind a cultcache-py[msgpack] extra that does not exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:47:07 +00:00
Libby
95d8be34a7 cultcache-rs: lookups cross-type test, changelog and README entries
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:45:56 +00:00
Libby
3250ba49d3 cultnet-py: error_message builder and closed-code/full-key validation in parse_message
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:45:52 +00:00
Libby
03b31f1f68 cultnet-ts: CultNetErrorMessage carries routingHint, code (closed union) and details; decode tests on C#/Rust bytes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:44:57 +00:00
Libby
2528fbc090 cultcache-rs: lookups parity tests ported from the C# suite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:44:36 +00:00
Libby
c4fae30677 cultcache-rs: name, index and global lookups from the C# reference
#[cultcache(name)], #[cultcache(index[ = alias])] and struct-level #[cultcache(global)]
in the DatabaseEntry derive; get_by_name, get_by_index and get_global on CultCache; a
second record of a global type under another key is a typed CultCacheSecondGlobal refusal
on every write path and on load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:44:36 +00:00
Libby
19f77ae3c2 heimdall-consent: README maps doc lines at Heimdall commit 4e99bfa
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:42:44 +00:00
Libby
b6f61a3869 cultnet error vectors: shared hex file for TS/Py, Rust pins it to the C# captures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:42:38 +00:00
Libby
5ab6a6181f heimdall-consent: live answers need text; ties on received_at break by bytewise event_id
Rulings: Heimdall PR #6 comment 759. Golden vectors for empty live text, empty revoked,
and received_at ties in both input orders.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:42:24 +00:00
Libby
a9a2d51c6a heimdall-consent: match Heimdall's rulings at doc commit 2364979
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Typed records, validate(), redacted([u8;32]) and the golden vectors follow
the nine rulings on Heimdall PR #6: u32 versions, 32-byte tombstone hash on
the exited Answer, scopeless replies stored with both fields absent, strict
training rule, fresh first reply per notice version. README cites doc lines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:27:06 +00:00
Libby
3a98eef12f cultnet-ts: drop unset optional keys from createDocumentPutMessage
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 41s
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 5m58s
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
MessagePack encodes undefined as nil, and the peer rejects a document whose
sourceRuntimeId/sourceAgentId/sourceRole/tags are nil. Omit absent keys; test
that the encoded message carries none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:10:00 +00:00
Libby
309c8c1ba3 heimdall-consent: typed records for the answer collector
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
NoticeAcceptance, Answer and MemberTerms cut from Heimdall PR #6
(docs/answer-collector-consent.md, head d836f29), with a golden vector
for versioned sample keys, scope-less exclusion from training, redaction
as hash-only tombstone and newcomer notice acceptance. Ambiguities are
listed in the README as questions for Heimdall.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 15:35:25 +00:00
Libby
be0502c897 docs(start): tested CultNet first-call snippets for C#, TypeScript, Rust, Python
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 1m18s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 56s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Failing after 43s
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
Each sends a typed document as a document_put_raw message over a loopback
pipe and applies it to a second cache; a second test per runtime fails when
docs/start/README.md stops showing the snippet verbatim.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:30:20 +00:00
Libby
8777ce4766 docs/start: two-client .io game recipe over CultCache + CultMesh (TS)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Failing after 37s
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Failing after 31s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Failing after 36s
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
Authority holds one arena document in a CultCache and serves it over
CultMesh; two cultmesh-browser clients join, move and leave, and see each
other. Tested with real WebSockets. Browser run, persistence, remote trust
and CI wiring are marked not yet on the recipe page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 12:20:06 +00:00
b88beddecb Merge pull request 'heimdall-stake: stake function and splitting-gain report (Rust, zero deps)' (#9) from libby/heimdall-stake into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-06 04:34:33 +00:00
Libby
84f932ec50 heimdall-stake: sybil note and golden vectors
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
README: the constant 1 in weight = 1 + log_b(amount/unit) is paid once per identity, so splitting
always pays until the log goes negative and no base fixes it; the defence lives outside the function.
Golden vectors (single and split k=1,2,10,100, three bases) computed with 60-digit decimal ln,
pinned by tests/golden.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:30:58 +00:00
Libby
ae33ba4842 heimdall-stake: stake function and splitting-gain report (Rust, zero deps)
weight = 1 + log_b(amount/unit) in integer cents and micro-weights with an integer-only
log, plus floor, per-identity cap and minimum amount per identity; splitting_gain/curve
report what splitting a budget over k identities does. 11 property tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:30:58 +00:00
cafffbf6d5 Merge pull request 'docs/start: verbatim drift check for C# and TS first-call blocks' (#7) from libby/start-drift-checks into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-05 12:43:13 +00:00
51445cb2af Merge pull request 'CultMath: Phacelle rows are bit-identical forward, backward and shuffled' (#5) from libby/phacelle-row-order into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-05 11:02:47 +00:00
6ba990d734 Merge pull request 'heimdall-delegation: delegation resolver (Rust, zero deps)' (#6) from libby/heimdall-delegation into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-05 11:02:24 +00:00
Libby
bb25bf04cb docs/start: verbatim drift check for the C# and TypeScript first-call blocks
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Both blocks now come from a snippet region in their test, and a second test
fails when docs/start/README.md stops showing it, as the Rust and Python
blocks already do. The TS block differs only in the package import specifier.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:43:55 +00:00
Libby
5cb5f35dcc heimdall-delegation: README caveat that the cap does not bind under ballot copying
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:41:42 +00:00
Libby
e2a39d8aef heimdall-delegation: pro-rata cap, revocation time on receipts, prior art
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Replace ascending-id trimming with pro-rata scaling by largest remainder
(ascending originator id breaks exact remainder ties only). Receipts carry
counted weight and the originator's latest covering revocation time.
Delegation::revoked becomes Option<u64>. Property tests: trim
order-independence, cap never exceeded, no delegator scaled more than another.
README: prior-art section, placeholders labelled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 05:26:37 +00:00
Libby
30318c18fc heimdall-delegation: delegation resolver with property tests
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Re-delegation to max depth, estate-of-delegator counting, per-estate cap on
the final holder, scope narrowing, direct override, revocation, uncast on
cycle/depth/trim, same-estate-only switch, per-person public receipts.
No stake function; weights are caller-supplied.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:09:07 +00:00
Libby
bbee623e29 CultMath: Phacelle rows are order-independent (forward, backward, shuffled)
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Pins that height rows built from math.phacelle (the C# mirror of cultmath_phacelle) are bit-identical
whatever order the rows are generated in, for a ring-buffer terrain. Golden fixture untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:35:41 +00:00
66c9f50993 Merge pull request 'docs/start: tested Rust and Python CultCache first calls' (#3) from libby/docs-start-rust-py into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-04 21:34:14 +00:00
83321353e6 Merge pull request 'docs: docs/start onboarding with tested CultCache first calls (C#, TS)' (#2) from libby/docs-start into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-04 20:33:56 +00:00
6d126d4b94 Merge pull request 'CultMath: regenerate lavapipe device bounds at 85ce65fa' (#4) from eureka/fbm-lavapipe-bounds into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-04 20:31:23 +00:00
883271f62c CultMath: regenerate lavapipe device bounds at 85ce65fa
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Exact-case counts move: cycles_arcsine 154 -> 155 and cycles_arctan2 6 -> 7 on both routes; worst u1 and result hashes unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:26:13 +00:00
85ce65fad8 Merge pull request 'CultMath: fbm, and the measured Lipschitz bound of phacelle' (#1) from libby/cultmath-fbm-lipschitz into main
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (push) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (push) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (push) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Has been cancelled
2026-11-04 19:19:08 +00:00
Libby
5d8e032d7f cultmath: glsl-parity header names the recording image and lavapipe/Mesa; asin/acos/atan2 oracle tolerance capped at 1 ulp
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
The glsl-parity.json header gains recorded_on (generator writes it; goldens untouched). CyclesOracleTests allows
cycles_arcsine, cycles_arccosine and cycles_arctan2 (glsl-parity families 95-97) 1 ulp from Blender, with the measured
cases named; a test pins the cap at 1 ulp, the three names, and that a 2 ulp miss still fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 17:22:13 +00:00
Libby
b57501b33e cultmath: phacelle-lipschitz prints usage and exits 2 on missing or malformed arguments
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Without arguments the tool threw IndexOutOfRange; a malformed number threw FormatException.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:33:18 +00:00
3b58b24a68 cultmath: re-record device-bounds-lavapipe.txt for cultmath_fbm
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
Recorded on Yggdrasil under llvmpipe (LLVM 20.1.2) with
packages/cultmath/tools/device/run-lavapipe.sh, WRITE_BOUNDS=1, at 990521b3.
Adds the lavapipe-glsl and lavapipe-hlsl rows for cultmath_fbm only. The
cycles_arcsine/arctan2 rows are left as committed (pre-existing asin/atan
oracle failures at base 94f20eee are out of scope).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 04:57:26 +00:00
libby
990521b3f1 cultmath: measure the Lipschitz bound of phacelle, with its tool and a pinning test
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh portability and performance / ubuntu-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest one-command multiplayer onboarding (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
CultMesh portability and performance / windows-latest reactive semantics and leases (pull_request) Has been cancelled
CultNet cross-runtime interop / C# / TypeScript / Rust / Python / Kotlin (pull_request) Has been cancelled
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
max singular value of the (cosine, sine) Jacobian <= max(10, 1.25 |side|) / (1 - normalization)
for normalization in [0, 0.9], |side| in [0.25, 25]; method and tightness in design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:58:18 +00:00
libby
2d0adb92cc cultmath: add fbm, the value-only octave sum of snoise, with its HLSL and GLSL mirrors
math.fbm(p, octaves, lacunarity, gain) and math.fbm(p) (four octaves, lacunarity 2, gain 0.5);
cultmath_fbm in the shaders, the GLSL regenerated by GlslLowering, a golden-fixture family
appended, FbmTests for the rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:52:18 +00:00
204 changed files with 14276 additions and 764 deletions

View file

@ -9,44 +9,6 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
getting-started:
name: ${{ matrix.os }} one-command multiplayer onboarding
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
steps:
- name: Check out CultLib
uses: actions/checkout@v4
with:
path: CultLib
- name: Check out the matching Eve contract
uses: actions/checkout@v4
with:
repository: GameCult/Eve
# Eve main is the contract this sample is written against; the codex
# branch this used to pin was never merged and stopped on 2026-08-25.
ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }}
path: Eve
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
- name: Run the documented one-command checkpoint
shell: pwsh
run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve
reactive-and-resource-lifetime: reactive-and-resource-lifetime:
name: ${{ matrix.os }} reactive semantics and leases name: ${{ matrix.os }} reactive semantics and leases
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@ -54,7 +16,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [windows-latest, ubuntu-latest] # The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR.
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
steps: steps:
- name: Check out CultLib - name: Check out CultLib

View file

@ -1,10 +1,9 @@
name: CultNet cross-runtime interop name: CultNet cross-runtime interop
# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge
# runner is Ubuntu-only, so a push or pull_request trigger would queue a job that can never run.
# Run it from GitHub or any Windows runner.
on: on:
push:
branches:
- main
pull_request:
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View file

@ -8,15 +8,26 @@ name: Publish packages
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin # directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
# consume the core stack. # consume the core stack.
# #
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or # Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package # version must match the version in the package manifest;
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather # scripts/release-after-suite.mjs refuses the release otherwise rather than
# than shipping a mislabelled artifact. # shipping a mislabelled artifact.
#
# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*`
# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from
# workflow_dispatch only, which publishes nothing). A tag run uses the workflow
# file at the tagged commit, not the one on main, so a tag on an older commit
# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that
# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag.
on: on:
push: push:
# cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled
# until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach
# `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing.
# (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs
# reads the list as `tags:` followed directly by its items.)
tags: tags:
- "cultcache-ts-v*"
- "cultcache-py-v*" - "cultcache-py-v*"
- "cultnet-py-v*" - "cultnet-py-v*"
- "cultmesh-py-v*" - "cultmesh-py-v*"

4
.gitignore vendored
View file

@ -322,6 +322,10 @@ node_modules/
!packages/cultmesh-browser/** !packages/cultmesh-browser/**
!packages/cultmesh-kotlin/ !packages/cultmesh-kotlin/
!packages/cultmesh-kotlin/** !packages/cultmesh-kotlin/**
!packages/heimdall-delegation/
!packages/heimdall-delegation/**
!packages/heimdall-stake/
!packages/heimdall-stake/**
packages/**/node_modules/ packages/**/node_modules/
packages/**/dist/ packages/**/dist/
packages/**/dist-test/ packages/**/dist-test/

View file

@ -35,10 +35,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSock
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
@ -231,18 +227,6 @@ Global
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -322,7 +306,6 @@ Global
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1} {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4} {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
{B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE} {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C} {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}

View file

@ -98,7 +98,8 @@ start with the maintained
series. Its first executable package-consumer checkpoint is series. Its first executable package-consumer checkpoint is
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). [`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
The real-process browser continuation is The real-process browser continuation is
[`samples/eve-browser-network`](samples/eve-browser-network/README.md). [`samples/eve-browser-network`](samples/eve-browser-network/README.md); it
needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`.
One documented command runs both layers: One documented command runs both layers:
```powershell ```powershell

View file

@ -112,8 +112,11 @@ not guess. Adding a code is therefore a coordinated change across every runtime,
copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in
`packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are `packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`, `selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns) and `unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant). `write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
## Live Invariants ## Live Invariants

View file

@ -28,7 +28,7 @@
"code": { "code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).", "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"], "type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized"] "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
}, },
"details": { "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.", "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,8 +36,8 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["field", "value", "asOf", "current"], "required": ["field", "value", "asOf", "current"],
"properties": { "properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "type": ["string", "null"] }, "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write.", "type": ["string", "null"] }, "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "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 } "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
} }

View file

@ -0,0 +1,346 @@
{
"vectors": [
{
"name": "snapshot_request_v1_defaults",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"shardId": null,
"shardEpoch": null
}
},
{
"name": "snapshot_request_v1_full_selection_and_shard",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"shardId": "shard-1",
"shardEpoch": 7
}
},
{
"name": "database_subscribe_v1_defaults",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m",
"subscriptionId": "sub",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"includeSnapshot": true,
"consumerRuntimeId": null,
"bodyIds": null,
"supportedBodyTransports": null
}
},
{
"name": "database_subscribe_v1_full",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m-full",
"subscriptionId": "sub-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"includeSnapshot": false,
"consumerRuntimeId": "consumer",
"bodyIds": [
"body-a"
],
"supportedBodyTransports": [
"rudp",
"quic"
]
}
},
{
"name": "snapshot_response_raw_v1_headers",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 2,
"asOf": 5,
"next": "next-cursor",
"headers": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": null
},
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-lo",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": [
"alpha",
"beta"
]
}
],
"documents": null,
"edges": null,
"shardId": "shard-1",
"shardEpoch": 3,
"shardLogSequence": 41
}
},
{
"name": "snapshot_response_raw_v1_documents_and_edges",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 1,
"asOf": 5,
"next": null,
"headers": null,
"documents": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": null,
"schemaVersion": null,
"schemaContentHash": null,
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"payloadEncoding": "messagepack",
"payload": "gaFrAQ==",
"sourceRuntimeId": null,
"sourceAgentId": null,
"sourceRole": null,
"tags": null
}
],
"edges": [
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": null,
"payload": null
},
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": "messagepack",
"payload": "AQID"
}
],
"shardId": null,
"shardEpoch": null,
"shardLogSequence": null
}
},
{
"name": "error_plain",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "boom",
"routingHint": null,
"code": null,
"details": null
}
},
{
"name": "error_selection_invalid",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "fields[0].index",
"value": "kind",
"asOf": null,
"current": null
}
}
},
{
"name": "error_cursor_stale",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 3,
"current": 9
}
}
},
{
"name": "error_cursor_invalid_no_details",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: bad",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "error_write_not_authorized",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "k",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -0,0 +1,166 @@
{
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
"vectors": [
{
"name": "selection_invalid_schemas_null",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: must not be empty",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "schemas",
"value": null,
"asOf": null,
"current": null
}
}
},
{
"name": "selection_invalid_field_value",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: blank key entry",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "keys",
"value": " ",
"asOf": null,
"current": null
}
}
},
{
"name": "cursor_stale",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: cursor is stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 1,
"current": 2
}
}
},
{
"name": "cursor_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: cannot parse cursor",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "reference_outside_target",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "reference_outside_target: outside declared target",
"routingHint": null,
"code": "reference_outside_target",
"details": null
}
},
{
"name": "variant_unsupported_subscription_ended",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": {
"field": "subscriptionId",
"value": "sub-1",
"asOf": null,
"current": null
}
}
},
{
"name": "variant_unsupported_no_subscription",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": null
}
},
{
"name": "unowned_schema",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
"routingHint": null,
"code": "unowned_schema",
"details": {
"field": "schemaId",
"value": "some.schema.v1",
"asOf": null,
"current": null
}
}
},
{
"name": "write_not_authorized",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized: not granted",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "some:key",
"asOf": null,
"current": null
}
}
},
{
"name": "unsupported_schema_version",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unsupported_schema_version: cultnet.hello.v1",
"routingHint": null,
"code": "unsupported_schema_version",
"details": {
"field": "schemaVersion",
"value": "cultnet.hello.v1",
"asOf": null,
"current": null
}
}
},
{
"name": "message_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "message_invalid: Raw put messages must contain a document map.",
"routingHint": null,
"code": "message_invalid",
"details": {
"field": "document",
"value": null,
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo.
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source | | F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep | | F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source | | F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` | | F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returned 404 anonymously on 2026-11-08 for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 | | F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` | | F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim | | F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
@ -276,11 +276,10 @@ remains:
history (Keep a Changelog headings). history (Keep a Changelog headings).
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`, - A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`. `cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
These go in **two pushes** (at most three tags per push, SKILL.md): These go in **two pushes** (at most three tags per push, SKILL.md). No
`cultcache-ts` first, because its tag triggers `publish-packages.yml` `-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's publish nothing;
npm publish job still runs, and to where, is checked before that push; follow-up `npm-publish-ts-packages` owns npm publishing.
follow-up `npm-publish-ts-packages` owns the rest.
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the - The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
tagged commit) is StreamPixels' B-cuts, not this campaign. tagged commit) is StreamPixels' B-cuts, not this campaign.
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass. - Size: about 60 lines of version and CHANGELOG text; a short Hands pass.

View file

@ -26,7 +26,7 @@ caught before it ships instead of written up afterward.
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag | | `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") | | `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag | | `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` | | `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml`, `workflow_dispatch` only; npm publishing is off (see "Coverage gaps") |
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` | | `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
@ -198,8 +198,8 @@ or a missing declaration refuses), and:
proof: with no tag of its own prefix it is a first release. The suite test proof: with no tag of its own prefix it is a first release. The suite test
that pins every declared prefix guards it: each prefix is declared once, each that pins every declared prefix guards it: each prefix is declared once, each
workflow tag trigger must be a declared prefix, and each such package's prefix workflow tag trigger must be a declared prefix, and each such package's prefix
must be a workflow trigger or, for the Caching Unity package the workflow does must be a workflow trigger or, for a package the workflow does not tag-trigger
not build, the prefix of tags that hold its own manifest at the tag's own (the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own
version, so a typo or a copied prefix fails the suite. Nothing releases version, so a typo or a copied prefix fails the suite. Nothing releases
without the suite passing; see below for how each release path runs it. without the suite passing; see below for how each release path runs it.
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
@ -352,10 +352,40 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
at unchanged source and CRLF checkout means this SDK or host differs from the at unchanged source and CRLF checkout means this SDK or host differs from the
one that built the tracked DLL. one that built the tracked DLL.
### Before pushing a tag
A tag run uses the workflow files at the tagged commit, not the ones on main, and
the forge's GitHub mirror forwards the tag, so both CI systems run it. Before
pushing any tag, resolve the commit it will point at and read the tag-triggering
workflows as they are at that commit:
```
sha=$(git rev-parse <commit-the-tag-will-point-at>)
git ls-tree -r --name-only "$sha" .github/workflows
git show "$sha":.github/workflows/publish-packages.yml | sed -n '/^on:/,/^jobs:/p'
```
Confirm that the tag's prefix is a trigger there if you mean it to publish, and
is not if you do not. Do the same for every other workflow listed that has a
`tags:` trigger. A guard merged after that commit does not protect a tag that
points before it.
`cultcache-ts-v0.15.0` was pushed on `7d292085`, which predates the guard, and
its push started a publish run on both systems (GitHub run 42392334869, forge run
454). The forge job log shows how it ended: the Publish step ran
`release-after-suite.mjs`, whose `npm test` failed (11 cross-runtime tests,
`cargo: not found` on the runner), so it printed "@gamecult/cultcache-ts's own
tests failed; not releasing." and never reached `npm publish`. Neither
`cultcache-ts` nor `@gamecult/cultcache-ts` exists on the npm registry
(both return 404, checked 2026-11-08). The GitHub run's own log needs a login
to read, so its failure cause is inferred from the identical workflow and sha.
### Coverage gaps ### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate `cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm
publishing is off until ruled on, and the cultcache-ts job runs from
`workflow_dispatch` only, publishing nothing) — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`, (`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags `src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags

View file

@ -16,10 +16,10 @@ this page.
| | CultCache | CultMath | Geometry | CultNet | CultMesh | | | CultCache | CultMath | Geometry | CultNet | CultMesh |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet | | **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
For the whole For the whole
picture of what each piece is for, see the picture of what each piece is for, see the
@ -34,6 +34,7 @@ which is what `dotnet test tests/GameCult.Caching.Tests --filter FirstCallTests`
cannot drift without the test failing. cannot drift without the test failing.
```csharp ```csharp
using System.Threading.Tasks;
using GameCult.Caching; using GameCult.Caching;
using GameCult.Caching.MessagePack; using GameCult.Caching.MessagePack;
using MessagePack; using MessagePack;
@ -47,20 +48,27 @@ public sealed class Item
[Key(2)] public int Value; [Key(2)] public int Value;
} }
var store = new SingleFileMessagePackBackingStore("first-call.cc"); public static class FirstCall
var cache = new CultCache(); {
cache.AddBackingStore(store); public static async Task<Item?> Run(string path)
var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 }); {
store.PushAll(); // write the .cc file var store = new SingleFileMessagePackBackingStore(path);
var cache = new CultCache();
cache.AddBackingStore(store);
var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 });
store.PushAll(); // write the .cc file
var reopened = new CultCache(); var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore("first-call.cc")); reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await reopened.PullAllBackingStoresAsync(); // read it back await reopened.PullAllBackingStoresAsync(); // read it back
var potion = reopened.Get<Item>(handle.Key); // by key var potion = reopened.Get<Item>(handle.Key); // by key
var byName = reopened.GetByName<Item>("Potion"); // by [CultName] var byName = reopened.GetByName<Item>("Potion"); // by [CultName]
return byName ?? potion;
}
}
``` ```
The test's `Item` is nested and internal, so it carries `[MessagePackObject(AllowPrivate = true)]`; the snippet above is the public-class form and is not itself compiled. More: `src/GameCult.Caching/README.md`. `FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`.
## TypeScript first call ## TypeScript first call
@ -90,23 +98,26 @@ const itemDocument = defineDocumentType({
}); });
const registry = defineDocumentRegistry(itemDocument); const registry = defineDocumentRegistry(itemDocument);
const cache = CultCache.builder() async function firstCall(path: string) {
.withRegistry(registry) const cache = CultCache.builder()
.withGenericStore(new SingleFileMessagePackBackingStore("first-call.cc")) .withRegistry(registry)
.build(); .withGenericStore(new SingleFileMessagePackBackingStore(path))
await cache.pullAllBackingStores(); .build();
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 }); await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });
const reopened = CultCache.builder() const reopened = CultCache.builder()
.withRegistry(registry) .withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore("first-call.cc")) .withGenericStore(new SingleFileMessagePackBackingStore(path))
.build(); .build();
await reopened.pullAllBackingStores(); await reopened.pullAllBackingStores();
const potion = reopened.get(itemDocument, "item:potion"); const potion = reopened.get(itemDocument, "item:potion");
const byName = reopened.getByName(itemDocument, "Potion"); const byName = reopened.getByName(itemDocument, "Potion");
return { potion, byName };
}
``` ```
The test imports from `../src/index`; an application imports `@gamecult/cultcache-ts`. More: The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More:
`packages/cultcache-ts/README.md`. `packages/cultcache-ts/README.md`.
## Rust first call ## Rust first call
@ -211,6 +222,244 @@ def first_call() -> Item:
return reopened.get_required(item_doc, "item:potion") return reopened.get_required(item_doc, "item:potion")
``` ```
## CultNet first calls
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
matching.
### C# CultNet first call
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
```csharp
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
[MessagePackObject]
public sealed class Note
{
[Key(0)] [CultName] public string Title = string.Empty;
[Key(1)] public string Body = string.Empty;
}
public static class CultNetFirstCall
{
public static async Task<Note?> Run()
{
var inbox = new CultCache(); // the receiver's cache
var documents = new CultNetDocumentRegistry();
var received = new TaskCompletionSource<bool>();
// Receive: a loopback listener that applies each raw document put to the inbox.
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
{
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
received.TrySetResult(true);
});
// Send: a typed document as a cultnet.document_put_raw.v0 message.
using var client = new TcpFramedCultNetSchemaClient();
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
client.SendCultNet(documents.CreateRawDocumentPutMessage(
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
return inbox.Get<Note>(handle.Key);
}
}
```
### TypeScript CultNet first call
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`.
```ts
import { createServer, connect } from "node:net";
import { z } from "zod";
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
createTcpFramedCultNetPeer,
defineCultNetDocumentBinding,
} from "@gamecult/cultnet-ts";
const noteDocument = defineDocumentType({
type: "note",
schemaId: "gamecult.first_call_note.v1",
schemaName: "gamecult.first_call_note",
schemaVersion: "gamecult.first_call_note.v1",
schema: z.object({ title: z.string(), body: z.string() }),
name: "title",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
async function firstCall() {
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
// Receiver: a loopback listener whose peer applies each document put to the inbox.
const received = new Promise<void>((resolve, reject) => {
const server = createServer((socket) => {
const peer = createTcpFramedCultNetPeer(socket, options);
peer.on("invalidMessage", reject);
peer.on("message", async (message) => {
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
await documents.applyRawDocumentPutMessage(inbox, message);
peer.close();
server.close();
resolve();
});
}).listen(0, "127.0.0.1", () => {
// Sender: connect, build a typed put message, send it.
const { port } = server.address() as { port: number };
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
sender.send(
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
title: "Hello",
body: "sent over CultNet",
}),
);
received.finally(() => sender.close());
});
});
await received;
return inbox.get(noteDocument, "note:hello");
}
```
### Rust CultNet first call
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
```rust
use std::net::{TcpListener, TcpStream};
use std::thread;
use cultnet_rs::{
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
#[cultcache(key = 0)]
title: String,
#[cultcache(key = 1)]
body: String,
}
fn first_call() -> anyhow::Result<Note> {
let mut documents = CultNetDocumentRegistry::new();
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
"gamecult.first_call_note.v1".to_string(),
"gamecult.first_call_note.v1".to_string(),
))?;
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
// Receiver: a loopback listener that applies the document put it receives to its own cache.
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let receiver = thread::spawn({
let (documents, profile) = (documents.clone(), profile("receiver"));
move || -> anyhow::Result<Note> {
let (stream, _) = listener.accept()?;
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
let mut inbox = CultCache::new();
inbox.register_entry_type::<Note>()?;
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
}
});
// Sender: build a typed put message, encode it for the wire, send one frame.
let put = documents.create_raw_document_put_message(
"put-1",
"note:hello",
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
CultNetDocumentPutOptions::default(),
)?;
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
.send("schema", &bytes)?;
receiver.join().expect("receiver thread")
}
```
### Python CultNet first call
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
```python
import socket
from dataclasses import asdict, dataclass
from cultcache_py import CultCache, define_document_type
from cultnet_py import (
apply_raw_document_record,
document_put_raw,
encode_frame,
parse_message,
read_frame,
schema_document_map,
)
@dataclass
class Note:
title: str
body: str
note_doc = define_document_type(
"note",
encode=lambda note: asdict(note),
decode=lambda raw: Note(**raw),
name="title",
)
def first_call() -> Note:
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
put = document_put_raw(
message_id="put-1",
key="note:hello",
schema_id=note_doc.catalog_entry().schema_id,
stored_at="2026-01-01T00:00:00Z",
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
)
sender.sendall(encode_frame(put.to_bytes()))
# Receive: read the frame, parse the message, apply its record to a second cache.
message = parse_message(read_frame(receiver.makefile("rb")))
assert message.schema_version == "cultnet.document_put_raw.v0"
inbox = CultCache.builder().register_document_type(note_doc).build()
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
sender.close()
receiver.close()
return inbox.get_required(note_doc, "note:hello")
```
## Where next ## Where next
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. - Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.

196
docs/start/io-game.md Normal file
View file

@ -0,0 +1,196 @@
# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript)
A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache
and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the
document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and
leave because both lease the same document.
## What ran, what did not
| Piece | Status |
| --- | --- |
| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
| Edge clamp, and the source below matching the files that ran | **tested** |
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below |
| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory |
| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only |
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet
message handling a provider owes, written in this recipe with the `ws` package.
## Run it
```
npm ci --ignore-scripts # repo root
(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command
cd docs/start/io-game && npm install
npm test # node --test arena.test.ts, Node 24
node serve.ts # http://127.0.0.1:8080, open it twice
```
Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe).
## schema.ts
```ts
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
export interface Player { x: number; y: number }
export interface ArenaState { players: Record<string, Player> }
// The one shared document: every player's position. The server owns it; clients lease it.
export const arenaDocument = defineDocumentType({
type: "io.arena_state",
schemaId: "io.arena_state.v1",
schema: { parse: (value: unknown) => value as ArenaState },
});
export const arenaKey = "arena:main";
export const verseId = "io.arena";
export const authorityRuntimeId = "io.arena-daemon";
export const arenaSize = 100;
```
## server.ts
```ts
import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
await cache.put(arenaDocument, arenaKey, { players: {} });
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
await new Promise<void>(resolve => http.listen(port, host, resolve));
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
const subscribers = new Map<WebSocket, string[]>();
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
const record = () => ({
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
payloadEncoding: "messagepack" as const,
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
sourceRuntimeId: authorityRuntimeId,
});
const publish = async (next: ArenaState) => {
await cache.put(arenaDocument, arenaKey, next);
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
}
};
let nextId = 0;
const operations: Record<string, (p: any) => Promise<object>> = {
join: async () => {
const id = `p${++nextId}`;
const state = cache.getRequired(arenaDocument, arenaKey);
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
return { id };
},
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
const state = cache.getRequired(arenaDocument, arenaKey);
const me = state.players[id];
if (!me) return { ok: false };
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
return { ok: true };
},
leave: async ({ id }: { id: string }) => {
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
await publish({ players: rest });
return { ok: true };
},
};
const wss = new WebSocketServer({ server: http });
wss.on("connection", (ws, req) => {
const odin = req.url === "/odin";
ws.on("close", () => subscribers.delete(ws));
ws.on("message", async raw => {
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
send(ws, {
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
verses: [{
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
}],
});
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
const handler = operations[m.operation];
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
send(ws, {
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
});
}
});
});
return {
odinUrl: `${endpoint}/odin`,
state: () => cache.getRequired(arenaDocument, arenaKey),
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
};
}
```
## client.ts
```ts
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
const mesh = await CultMeshBrowserClient.connect({
verseId, authorityRuntimeId, runtimeId, rendezvous,
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
});
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
const call = async (operation: string, payload: object) =>
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
const { id } = await call("join", {});
lease.watch(record => {
if (!record) return;
latest = decodeCultNetPayload(record, arenaDocument);
onState(latest, id!);
});
return {
id: id!,
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
state: () => latest,
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
};
}
```
`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild.

View file

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { joinArena } from "./client.ts";
import { startArena } from "./server.ts";
import { arenaSize, type ArenaState } from "./schema.ts";
const until = async (cond: () => boolean) => {
for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10));
assert.ok(cond(), "condition not reached in 2s");
};
test("two clients join one arena, see each other, and see each other move", async () => {
const arena = await startArena();
const seenByA: ArenaState[] = [];
const seenByB: ArenaState[] = [];
const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s)));
const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s)));
try {
assert.notEqual(a.id, b.id);
await until(() => Object.keys(a.state()?.players ?? {}).length === 2);
await until(() => Object.keys(b.state()?.players ?? {}).length === 2);
const before = structuredClone(b.state()!.players[a.id]!);
// Moves clamp at the arena edge, so pick a direction that always has room to move.
const dx = before.x > 0 ? -1 : 1;
await a.move(dx, 0);
await until(() => b.state()!.players[a.id]!.x !== before.x);
assert.equal(b.state()!.players[a.id]!.x, before.x + dx);
assert.deepEqual(a.state(), arena.state());
assert.deepEqual(b.state(), arena.state());
await b.leave();
await until(() => !(a.id !== b.id && b.id in a.state()!.players));
assert.deepEqual(Object.keys(a.state()!.players), [a.id]);
} finally {
await a.leave().catch(() => undefined);
await arena.close();
}
});
test("a player cannot walk out of the arena", async () => {
const arena = await startArena();
const a = await joinArena(arena.odinUrl, "client-a", () => undefined);
try {
for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0);
assert.equal(arena.state().players[a.id]!.x, 0);
} finally {
await a.leave();
await arena.close();
}
});
test("serve.ts bundles the browser client and serves the page", async () => {
const { serveArena } = await import("./serve.ts");
const site = await serveArena(0);
try {
const html = await (await fetch(site.url)).text();
assert.match(html, /<canvas/);
const js = await (await fetch(`${site.url}/browser.js`)).text();
assert.match(js, /CultMeshBrowserClient/);
} finally {
await site.close();
}
});
test("the recipe page shows the source files verbatim", () => {
const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
for (const file of ["schema.ts", "server.ts", "client.ts"]) {
const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n");
assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`);
}
});

View file

@ -0,0 +1,17 @@
import { joinArena } from "./client.ts";
import { arenaSize, type ArenaState } from "./schema.ts";
const canvas = document.querySelector("canvas")!;
const ctx = canvas.getContext("2d")!;
const scale = canvas.width / arenaSize;
const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const [id, p] of Object.entries(state.players)) {
ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff";
ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2);
}
});
const keys: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); });
addEventListener("pagehide", () => void game.leave());

View file

@ -0,0 +1,28 @@
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
const mesh = await CultMeshBrowserClient.connect({
verseId, authorityRuntimeId, runtimeId, rendezvous,
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
});
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
const call = async (operation: string, payload: object) =>
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
const { id } = await call("join", {});
lease.watch(record => {
if (!record) return;
latest = decodeCultNetPayload(record, arenaDocument);
onState(latest, id!);
});
return {
id: id!,
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
state: () => latest,
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
};
}

View file

@ -0,0 +1,6 @@
<!doctype html>
<meta charset="utf-8">
<title>arena</title>
<canvas width="500" height="500" style="border:1px solid #888"></canvas>
<p>Arrow keys. Open this page in a second window.</p>
<script type="module" src="/browser.js"></script>

64
docs/start/io-game/package-lock.json generated Normal file
View file

@ -0,0 +1,64 @@
{
"name": "cultlib-start-io-game",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cultlib-start-io-game",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.0"
}
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.22.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -0,0 +1,9 @@
{
"name": "cultlib-start-io-game",
"private": true,
"type": "module",
"description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).",
"scripts": { "test": "node --test arena.test.ts" },
"dependencies": { "ws": "^8.18.0" },
"devDependencies": { "@types/ws": "^8.5.0" }
}

View file

@ -0,0 +1,16 @@
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
export interface Player { x: number; y: number }
export interface ArenaState { players: Record<string, Player> }
// The one shared document: every player's position. The server owns it; clients lease it.
export const arenaDocument = defineDocumentType({
type: "io.arena_state",
schemaId: "io.arena_state.v1",
schema: { parse: (value: unknown) => value as ArenaState },
});
export const arenaKey = "arena:main";
export const verseId = "io.arena";
export const authorityRuntimeId = "io.arena-daemon";
export const arenaSize = 100;

View file

@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { build } from "esbuild";
import { startArena } from "./server.ts";
// Serves the page, the bundled browser client, and the arena's WebSocket on one port.
export async function serveArena(port = 8080, host = "127.0.0.1") {
const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false });
const html = readFileSync(new URL("./index.html", import.meta.url));
const arena = await startArena(port, host, (req, res) => {
if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html);
else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents);
else res.writeHead(404).end();
});
return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close };
}
if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`);

View file

@ -0,0 +1,100 @@
import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
await cache.put(arenaDocument, arenaKey, { players: {} });
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
await new Promise<void>(resolve => http.listen(port, host, resolve));
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
const subscribers = new Map<WebSocket, string[]>();
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
const record = () => ({
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
payloadEncoding: "messagepack" as const,
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
sourceRuntimeId: authorityRuntimeId,
});
const publish = async (next: ArenaState) => {
await cache.put(arenaDocument, arenaKey, next);
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
}
};
let nextId = 0;
const operations: Record<string, (p: any) => Promise<object>> = {
join: async () => {
const id = `p${++nextId}`;
const state = cache.getRequired(arenaDocument, arenaKey);
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
return { id };
},
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
const state = cache.getRequired(arenaDocument, arenaKey);
const me = state.players[id];
if (!me) return { ok: false };
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
return { ok: true };
},
leave: async ({ id }: { id: string }) => {
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
await publish({ players: rest });
return { ok: true };
},
};
const wss = new WebSocketServer({ server: http });
wss.on("connection", (ws, req) => {
const odin = req.url === "/odin";
ws.on("close", () => subscribers.delete(ws));
ws.on("message", async raw => {
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
send(ws, {
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
verses: [{
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
}],
});
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
const handler = operations[m.operation];
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
send(ws, {
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
});
}
});
});
return {
odinUrl: `${endpoint}/odin`,
state: () => cache.getRequired(arenaDocument, arenaKey),
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
};
}

41
package-lock.json generated
View file

@ -482,6 +482,18 @@
"resolved": "packages/cultcache-ts", "resolved": "packages/cultcache-ts",
"link": true "link": true
}, },
"node_modules/@gamecult/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/@gamecult/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/@gamecult/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/@jsdevtools/ono": { "node_modules/@jsdevtools/ono": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
@ -865,18 +877,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.1", "version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -1184,7 +1184,7 @@
}, },
"packages/cultcache-ts": { "packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.14.2", "version": "0.15.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
@ -1200,15 +1200,16 @@
} }
}, },
"packages/cultmesh-browser": { "packages/cultmesh-browser": {
"name": "@gamecult/cultmesh-browser",
"version": "0.4.0", "version": "0.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@gamecult/cultmesh-ts": "^0.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"cultmesh-ts": "^0.2.0",
"typescript": "^5.9.0" "typescript": "^5.9.0"
}, },
"engines": { "engines": {
@ -1216,11 +1217,12 @@
} }
}, },
"packages/cultmesh-ts": { "packages/cultmesh-ts": {
"name": "@gamecult/cultmesh-ts",
"version": "0.2.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.3.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {
@ -1233,10 +1235,11 @@
} }
}, },
"packages/cultnet-ts": { "packages/cultnet-ts": {
"name": "@gamecult/cultnet-ts",
"version": "0.3.0", "version": "0.3.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.1", "@gamecult/cultcache-ts": "^0.15.0",
"@msgpack/msgpack": "^3.1.3", "@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1" "ajv": "^8.17.1"
}, },

View file

@ -2,6 +2,11 @@
## Unreleased ## Unreleased
### Changed
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used.
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.

View file

@ -6,6 +6,9 @@ from collections.abc import Mapping, Sequence, Set
from itertools import chain from itertools import chain
from typing import Any, Callable, Generic, Iterable, TypeVar from typing import Any, Callable, Generic, Iterable, TypeVar
import msgpack
from msgpack.exceptions import StackError as _MsgpackStackError
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
from .leaves import Float32, SlotCodec from .leaves import Float32, SlotCodec
from .leaves import any as _any_leaf from .leaves import any as _any_leaf
@ -24,12 +27,7 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused,
MAX_SLOT_KEY = 65535 MAX_SLOT_KEY = 65535
"""The largest slot key a record may define: the largest MessagePack array16 length.""" """The largest slot key a record may define: the largest MessagePack array16 length."""
try: _STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
from msgpack.exceptions import StackError as _MsgpackStackError
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
except ModuleNotFoundError:
_STACK_FAULTS = ()
_CONTAINERS = (Mapping, Sequence, Set) _CONTAINERS = (Mapping, Sequence, Set)
@ -85,22 +83,10 @@ def _json_payload_decoder(payload: bytes) -> Any:
return json.loads(payload.decode("utf-8")) return json.loads(payload.decode("utf-8"))
def _msgpack() -> Any:
try:
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"MessagePack payload encoding requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack
def _pack_tree(tree: Any) -> bytes: def _pack_tree(tree: Any) -> bytes:
"""The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a """The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a
TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a
single-float packer.""" single-float packer."""
msgpack = _msgpack()
try: try:
return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return] return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return]
except TypeError: except TypeError:
@ -125,13 +111,6 @@ def _pack_tree(tree: Any) -> bytes:
def _messagepack_payload_decoder(payload: bytes) -> Any: def _messagepack_payload_decoder(payload: bytes) -> Any:
try:
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"MessagePack payload decoding requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack.unpackb(payload, raw=False) return msgpack.unpackb(payload, raw=False)

View file

@ -16,10 +16,7 @@ from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, NoReturn from typing import Any, Callable, NoReturn
try: from msgpack import ExtType as _ExtType
from msgpack import ExtType as _ExtType
except ModuleNotFoundError:
_ExtType = () # type: ignore[assignment,misc]
_bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str _bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str
_BYTE_LIKE = (_bytes, bytearray, memoryview) _BYTE_LIKE = (_bytes, bytearray, memoryview)

View file

@ -3,9 +3,15 @@
import base64 import base64
import json import json
import os import os
import sys
import threading import threading
import time
import uuid
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Callable, TypeVar from typing import Any, Callable, Iterator, TypeVar
import msgpack
from .backing_store import ( from .backing_store import (
CultCacheEnvelope, CultCacheEnvelope,
@ -21,6 +27,8 @@ STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3"
_READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS) _READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS)
_STORE_FORMAT_PREFIX = "cultcache.store." _STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4 _PERSISTED_RECORD_SLOTS = 4
_LOCK_POLL_SECONDS = 0.01
LOCK_TIMEOUT_SECONDS = 30.0
_Contents = TypeVar("_Contents") _Contents = TypeVar("_Contents")
@ -114,14 +122,14 @@ class JsonLinesBackingStore:
class SingleFileMessagePackBackingStore: class SingleFileMessagePackBackingStore:
def __init__(self, path: str | os.PathLike[str]) -> None: def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None:
self.path = Path(path) self.path = Path(path)
self.lock_timeout = lock_timeout
self._lock = threading.RLock() self._lock = threading.RLock()
# The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker. # The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker.
self._format = STORE_FORMAT_VERSION self._format = STORE_FORMAT_VERSION
def pull_all(self) -> list[CultCacheEnvelope]: def pull_all(self) -> list[CultCacheEnvelope]:
msgpack = self._msgpack()
with self._lock: with self._lock:
# An empty file is not a store (no writer leaves one), so it is decoded and refused. # An empty file is not a store (no writer leaves one), so it is decoded and refused.
# The disk decides the header: a file that is gone is not marked. # The disk decides the header: a file that is gone is not marked.
@ -135,39 +143,34 @@ class SingleFileMessagePackBackingStore:
if snapshot is not None: if snapshot is not None:
self._format, envelopes = snapshot self._format, envelopes = snapshot
return envelopes return envelopes
return _decode_legacy_envelopes(decoded, msgpack) return _decode_legacy_envelopes(decoded)
except Exception as exc: except Exception as exc:
raise StoreUnreadableError(self.path, exc) from exc raise StoreUnreadableError(self.path, exc) from exc
def push(self, envelope: CultCacheEnvelope) -> None: def push(self, envelope: CultCacheEnvelope) -> None:
with self._lock: with self._write_transaction():
self._replace_all([envelope], self.pull_all()) self._replace_all([envelope], self.pull_all())
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
with self._lock: with self._write_transaction():
self._replace_all(list(envelopes), self.pull_all()) self._replace_all(list(envelopes), self.pull_all())
def delete(self, type: str, key: str) -> None: def delete(self, type: str, key: str) -> None:
with self._lock: with self._write_transaction():
self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)]) self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)])
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None: @contextmanager
msgpack = self._msgpack() def _write_transaction(self) -> Iterator[None]:
self.path.parent.mkdir(parents=True, exist_ok=True) """The thread lock, then the cross-process lock on `<path>.lock`, held across the read and the replace so two
temp = self.path.with_suffix(self.path.suffix + ".tmp") processes never rewrite from the same stale read."""
temp.write_bytes(msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True)) with self._lock:
temp.replace(self.path) self.path.parent.mkdir(parents=True, exist_ok=True)
with store_write_lock(self.path, self.lock_timeout):
yield
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None:
commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True))
@staticmethod
def _msgpack() -> Any:
try:
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack
def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]: def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]:
@ -261,6 +264,85 @@ def _encode_snapshot(
] ]
return [format_version, catalog, records] return [format_version, catalog, records]
def commit_store_file(path: Path, data: bytes) -> None:
"""Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows,
fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the
store untouched. The caller holds the store lock."""
staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}")
try:
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
try:
view = memoryview(data)
while view:
view = view[os.write(fd, view):]
os.fsync(fd)
finally:
os.close(fd)
os.replace(staged, path)
except BaseException:
try:
os.unlink(staged)
except OSError:
pass
raise
if sys.platform != "win32":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
@contextmanager
def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix,
LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is
created if absent and never written or deleted."""
lock_path = path.with_name(path.name + ".lock")
deadline = time.monotonic() + timeout
while True:
release = _try_lock(lock_path)
if release is not None:
break
if time.monotonic() >= deadline:
raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.")
time.sleep(_LOCK_POLL_SECONDS)
try:
yield
finally:
release()
def _try_lock(lock_path: Path) -> Callable[[], None] | None:
if sys.platform == "win32":
return _try_lock_windows(lock_path)
import fcntl
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
return None
return lambda: os.close(fd) # closing drops the flock
def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None:
import ctypes
import msvcrt
from ctypes import wintypes
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p]
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666)
handle = msvcrt.get_osfhandle(fd)
overlapped = ctypes.create_string_buffer(32)
# LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it.
if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped):
os.close(fd)
return None
return lambda: os.close(fd) # closing drops the lock
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None: def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
"""Reads a store file, or returns None when nothing is at its path. """Reads a store file, or returns None when nothing is at its path.
@ -351,7 +433,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
return decoded[0], envelopes return decoded[0], envelopes
def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]: def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
if not isinstance(decoded, list): if not isinstance(decoded, list):
raise ValueError("CultCache MessagePack store is not a recognized snapshot") raise ValueError("CultCache MessagePack store is not a recognized snapshot")
envelopes: list[CultCacheEnvelope] = [] envelopes: list[CultCacheEnvelope] = []

View file

@ -0,0 +1,54 @@
from __future__ import annotations
import unittest
from pathlib import Path
import msgpack
from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t
# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a
# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites
# the same bytes.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil"
def _probe():
inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)])
return define_database_entry_type(
"tests.slot-nil",
[
database_entry_field("s", 0, default="", codec=t.nullable(t.string)),
database_entry_field("i", 1, default=0, codec=t.int32),
database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)),
database_entry_field("r", 3, default=None, codec=t.nullable(inner)),
database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))),
],
)
class SlotNilVectorTests(unittest.TestCase):
def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None:
rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)
probe = _probe()
self.assertEqual(17, len(rows))
accepted = 0
for name, payload, was_accepted, rewritten in rows:
with self.subTest(name):
if was_accepted:
self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex())
accepted += 1
else:
with self.assertRaises(Exception):
probe.decode_payload(payload)
self.assertEqual(15, accepted)
def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None:
rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)}
probe = _probe()
self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"])
self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,135 @@
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
from unittest import mock
from cultcache_py import SingleFileMessagePackBackingStore, stores
from cultcache_py.backing_store import CultCacheEnvelope
REPO = Path(__file__).resolve().parents[3]
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
HOLD_SECONDS = 1.5
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
PY_HOLDER = """
import fcntl, os, sys, time
fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666)
fcntl.flock(fd, fcntl.LOCK_EX)
print('HELD', flush=True)
time.sleep(float(sys.argv[2]))
"""
# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts.
TS_HOLDER = """
const { withStoreWriteLock } = require(process.argv[1]);
withStoreWriteLock(process.argv[2], async () => {
console.log('HELD');
await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000));
});
"""
def envelope(key: str) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
def start_holder(argv: list[str]) -> subprocess.Popen[str]:
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD"
holder.held_at = time.monotonic() # type: ignore[attr-defined]
return holder
@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder")
class StoreLockTests(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None:
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
with self.assertRaises(TimeoutError):
refused.push(envelope("blocked"))
self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store")
SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after"))
self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined]
self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()])
def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None:
self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]))
@unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)")
def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None:
holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)])
self.assert_write_waits_for(holder)
def test_the_lock_is_released_after_each_write(self) -> None:
store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
for key in "abc":
store.push(envelope(key))
self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all()))
def test_concurrent_processes_lose_no_writes(self) -> None:
script = (
"import sys\n"
"from cultcache_py import SingleFileMessagePackBackingStore as S\n"
"from cultcache_py.backing_store import CultCacheEnvelope as E\n"
"s = S(sys.argv[1])\n"
"for i in range(25):\n"
" s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n"
)
env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])}
procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"]
for proc in procs:
self.assertEqual(0, proc.wait())
self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all()))
class StagedWriteTests(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def test_the_staged_file_is_fsynced_before_the_replace(self) -> None:
order: list[str] = []
real_fsync, real_replace = os.fsync, os.replace
with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch(
"os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1]
):
SingleFileMessagePackBackingStore(self.path).push(envelope("a"))
self.assertEqual("fsync", order[0])
self.assertLess(order.index("fsync"), order.index("replace"))
def test_the_staged_name_is_unique_per_write(self) -> None:
names: list[str] = []
real_replace = os.replace
with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]):
store = SingleFileMessagePackBackingStore(self.path)
store.push(envelope("a"))
store.push(envelope("b"))
self.assertEqual(2, len(set(names)))
self.assertNotIn(os.fspath(self.path) + ".tmp", names)
def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None:
store = SingleFileMessagePackBackingStore(self.path)
store.push(envelope("a"))
before = self.path.read_bytes()
with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError):
store.push(envelope("b"))
self.assertEqual(before, self.path.read_bytes())
self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name])
if __name__ == "__main__":
unittest.main()

View file

@ -13,6 +13,7 @@ only in git history.
### Added ### Added
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`. - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
## [0.3.1] ## [0.3.1]

View file

@ -174,6 +174,9 @@ without pretending Rust has runtime reflection hiding under the floorboards.
- `get_required_envelope::<T>` - `get_required_envelope::<T>`
- `get_all::<T>` - `get_all::<T>`
- `get_all_with_keys::<T>` - `get_all_with_keys::<T>`
- `get_by_name::<T>`
- `get_by_index::<T>`
- `get_global::<T>`
- `soa::<T>` - `soa::<T>`
- `put::<T>` - `put::<T>`
- `put_envelope::<T>` - `put_envelope::<T>`

View file

@ -22,6 +22,7 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
let ident = input.ident.clone(); let ident = input.ident.clone();
let mut entry_type: Option<LitStr> = None; let mut entry_type: Option<LitStr> = None;
let mut schema_name: Option<LitStr> = None; let mut schema_name: Option<LitStr> = None;
let mut global = false;
for attribute in input for attribute in input
.attrs .attrs
@ -37,6 +38,12 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
once(&schema_name, &meta, "schema")?; once(&schema_name, &meta, "schema")?;
schema_name = Some(meta.value()?.parse()?); schema_name = Some(meta.value()?.parse()?);
Ok(()) Ok(())
} else if meta.path.is_ident("global") {
if global {
return Err(meta.error("repeated `global`: #[cultcache(global)] is given once"));
}
global = true;
Ok(())
} else { } else {
Err(meta.error("unsupported cultcache attribute")) Err(meta.error("unsupported cultcache attribute"))
} }
@ -54,11 +61,40 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
let slots = named_slots(input.data, &ident, "DatabaseEntry")?; let slots = named_slots(input.data, &ident, "DatabaseEntry")?;
let codec = slot_codec(&ident, &slots); let codec = slot_codec(&ident, &slots);
let name_member = slots.iter().find(|slot| slot.name).map(|slot| &slot.ident);
let name_fn = name_member.map(|member| {
quote! {
fn cult_name(&self) -> ::std::option::Option<::std::string::String> {
::cultcache_rs::CultLookupValue::lookup_value(&self.#member)
}
}
});
let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident)));
let index_pushes = index_members.map(|(alias, member)| {
quote! {
if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) {
held.push((#alias, value));
}
}
});
let index_pushes: Vec<_> = index_pushes.collect();
let indexes_fn = (!index_pushes.is_empty()).then(|| {
quote! {
fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> {
let mut held = ::std::vec::Vec::new();
#(#index_pushes)*
held
}
}
});
Ok(quote! { Ok(quote! {
impl ::cultcache_rs::DatabaseEntry for #ident { impl ::cultcache_rs::DatabaseEntry for #ident {
const TYPE: &'static str = #entry_type; const TYPE: &'static str = #entry_type;
const SCHEMA_NAME: &'static str = #schema_name; const SCHEMA_NAME: &'static str = #schema_name;
const GLOBAL: bool = #global;
#name_fn
#indexes_fn
} }
#codec #codec
@ -133,6 +169,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
let mut key: Option<usize> = None; let mut key: Option<usize> = None;
let mut default = false; let mut default = false;
let mut bytes = false; let mut bytes = false;
let mut name = false;
let mut index: Option<String> = None;
for attribute in field for attribute in field
.attrs .attrs
.iter() .iter()
@ -157,6 +195,22 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
} else if meta.path.is_ident("bytes") { } else if meta.path.is_ident("bytes") {
bytes = true; bytes = true;
Ok(()) Ok(())
} else if owner == "DatabaseEntry" && meta.path.is_ident("name") {
if name {
return Err(meta.error("repeated `name`: #[cultcache(name)] is given once"));
}
name = true;
Ok(())
} else if owner == "DatabaseEntry" && meta.path.is_ident("index") {
once(&index, &meta, "index")?;
// A bare `index`, or an alias that is blank, names the field itself.
let alias = if meta.input.peek(syn::Token![=]) {
meta.value()?.parse::<LitStr>()?.value()
} else {
String::new()
};
index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias });
Ok(())
} else { } else {
Err(meta.error("unsupported field cultcache attribute")) Err(meta.error("unsupported field cultcache attribute"))
} }
@ -180,8 +234,28 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
ty: field.ty, ty: field.ty,
default, default,
bytes, bytes,
name,
index,
}); });
} }
if let Some(second) = slots.iter().filter(|slot| slot.name).nth(1) {
return Err(syn::Error::new_spanned(
&second.ident,
format!("{owner} declares #[cultcache(name)] on more than one member; a record has one name"),
));
}
for (position, slot) in slots.iter().enumerate() {
let Some(alias) = &slot.index else { continue };
if let Some(first) = slots[..position].iter().find(|earlier| earlier.index.as_ref() == Some(alias)) {
return Err(syn::Error::new_spanned(
&slot.ident,
format!(
"{owner} members {} and {} both declare index alias \"{alias}\"; an index alias must name one member",
first.ident, slot.ident
),
));
}
}
slots.sort_by_key(|slot| slot.key); slots.sort_by_key(|slot| slot.key);
Ok(slots) Ok(slots)
} }
@ -367,6 +441,10 @@ struct CultCacheSlot {
ty: Type, ty: Type,
default: bool, default: bool,
bytes: bool, bytes: bool,
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
name: bool,
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: the alias `get_by_index` finds the record by.
index: Option<String>,
} }
fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> { fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
@ -842,4 +920,89 @@ mod tests {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted"); named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted");
} }
fn entry_error(source: &str) -> String {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
database_entry_codec(input).expect_err("the fixture is refused").to_string()
}
fn entry_tokens(source: &str) -> String {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "")
}
#[test]
fn name_index_and_global_generate_the_lookup_surface() {
let tokens = entry_tokens(
r#"#[cultcache(type = "t", global)] struct S {
#[cultcache(key = 0, name)] label: String,
#[cultcache(key = 1, index)] code: String,
#[cultcache(key = 2, index = "sku")] stock: Option<String>,
}"#,
);
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
assert!(tokens.contains("fncult_name"), "{tokens}");
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
assert!(tokens.contains("(\"sku\",value)"), "{tokens}");
}
#[test]
fn a_record_without_lookups_generates_none_of_them() {
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}");
}
// ResolveIndexAlias: a blank alias is the member's name.
#[test]
fn a_blank_index_alias_is_the_member_name() {
let tokens = entry_tokens(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
);
assert!(tokens.contains("(\"code\",value)"), "{tokens}");
}
#[test]
fn two_names_are_refused_for_that_reason() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#,
);
assert!(message.contains("more than one member"), "{message}");
}
// D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it.
#[test]
fn two_members_with_one_index_alias_are_refused_for_that_reason() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#,
);
assert!(message.contains("both declare index alias \"k\""), "{message}");
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#,
);
assert!(message.contains("both declare index alias \"a\""), "{message}");
}
#[test]
fn a_repeated_lookup_attribute_is_refused() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#,
);
assert!(message.contains("repeated `name`"), "{message}");
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#,
);
assert!(message.contains("repeated `index`"), "{message}");
let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(message.contains("repeated `global`"), "{message}");
}
// Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before.
#[test]
fn lookup_attributes_are_refused_on_a_slots_record() {
let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#);
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#);
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
}
} }

View file

@ -162,8 +162,68 @@ pub use cultcache_rs_derive::DatabaseEntry;
pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static {
const TYPE: &'static str; const TYPE: &'static str;
const SCHEMA_NAME: &'static str = "DatabaseEntry"; const SCHEMA_NAME: &'static str = "DatabaseEntry";
/// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`.
const GLOBAL: bool = false;
/// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name.
fn cult_name(&self) -> Option<String> {
None
}
/// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by.
fn cult_indexes(&self) -> Vec<(&'static str, String)> {
Vec::new()
}
} }
/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member
/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none.
pub trait CultLookupValue {
fn lookup_value(&self) -> Option<String>;
}
impl CultLookupValue for String {
fn lookup_value(&self) -> Option<String> {
Some(self.clone())
}
}
impl<T: CultLookupValue> CultLookupValue for Option<T> {
fn lookup_value(&self) -> Option<String> {
self.as_ref().and_then(CultLookupValue::lookup_value)
}
}
macro_rules! lookup_value_by_display {
($($ty:ty),* $(,)?) => {$(
impl CultLookupValue for $ty {
fn lookup_value(&self) -> Option<String> {
Some(self.to_string())
}
}
)*};
}
lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize);
/// A global type already holds a record under another key; a second would leave `get_global` with two answers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CultCacheSecondGlobal {
pub schema_name: String,
pub existing_key: String,
pub key: String,
}
impl std::fmt::Display for CultCacheSecondGlobal {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"{} is a global and already has record {}; {} would be a second.",
self.schema_name, self.existing_key, self.key
)
}
}
impl std::error::Error for CultCacheSecondGlobal {}
/// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry` /// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry`
/// derives implement, and what a `CultUnion` arm must carry. /// derives implement, and what a `CultUnion` arm must carry.
#[diagnostic::on_unimplemented( #[diagnostic::on_unimplemented(
@ -2655,6 +2715,8 @@ struct CultCacheStoreRegistration {
pub struct CultCache { pub struct CultCache {
definitions: BTreeMap<String, &'static str>, definitions: BTreeMap<String, &'static str>,
/// The registered types declared `#[cultcache(global)]`.
globals: BTreeSet<String>,
schema_name_definitions: BTreeMap<String, String>, schema_name_definitions: BTreeMap<String, String>,
entries: BTreeMap<(String, String), CultCacheEnvelope>, entries: BTreeMap<(String, String), CultCacheEnvelope>,
stores: Vec<CultCacheStoreRegistration>, stores: Vec<CultCacheStoreRegistration>,
@ -2664,6 +2726,7 @@ impl CultCache {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
definitions: BTreeMap::new(), definitions: BTreeMap::new(),
globals: BTreeSet::new(),
schema_name_definitions: BTreeMap::new(), schema_name_definitions: BTreeMap::new(),
entries: BTreeMap::new(), entries: BTreeMap::new(),
stores: Vec::new(), stores: Vec::new(),
@ -2694,6 +2757,11 @@ impl CultCache {
)); ));
} }
self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME);
if T::GLOBAL {
self.globals.insert(T::TYPE.to_string());
} else {
self.globals.remove(T::TYPE);
}
self.schema_name_definitions self.schema_name_definitions
.insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string());
Ok(()) Ok(())
@ -2800,6 +2868,7 @@ impl CultCache {
loaded.insert(entry_id(&entry), entry); loaded.insert(entry_id(&entry), entry);
} }
} }
self.refuse_second_globals(&loaded)?;
self.entries = loaded; self.entries = loaded;
Ok(()) Ok(())
} }
@ -2883,6 +2952,65 @@ impl CultCache {
Ok(values) Ok(values)
} }
/// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is
/// not a global).
pub fn get_global<T: DatabaseEntry>(&self) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if !T::GLOBAL {
return Ok(None);
}
self.find_record::<T>(|_| true)
}
/// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records
/// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or
/// loaded in; removing it finds the next.
pub fn get_by_name<T: DatabaseEntry>(&self, name: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if name.is_empty() {
return Ok(None);
}
self.find_record::<T>(|document| document.cult_name().as_deref() == Some(name))
}
/// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds
/// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they
/// were written or loaded in; removing it finds the next.
pub fn get_by_index<T: DatabaseEntry>(&self, alias: &str, value: &str) -> Result<Option<T>> {
self.require_entry_type::<T>()?;
if value.trim().is_empty() {
return Ok(None);
}
self.find_record::<T>(|document| {
document
.cult_indexes()
.iter()
.any(|(held_alias, held_value)| *held_alias == alias && held_value == value)
})
}
/// The first record of `T`, in key order, that `matches`.
fn find_record<T: DatabaseEntry>(&self, matches: impl Fn(&T) -> bool) -> Result<Option<T>> {
let held = self
.entries
.range((T::TYPE.to_string(), String::new())..)
.take_while(|((held_type, _), _)| held_type == T::TYPE);
for (_, entry) in held {
let document: T = decode_payload(&entry.payload).with_context(|| {
format!(
"failed to decode CultCache entry {:?} at key {:?} as {}",
T::TYPE,
entry.key,
T::SCHEMA_NAME
)
})?;
if matches(&document) {
return Ok(Some(document));
}
}
Ok(None)
}
pub fn soa<T: SoaDocument>(&self) -> Result<CultSoaTable<T>> { pub fn soa<T: SoaDocument>(&self) -> Result<CultSoaTable<T>> {
let keyed_rows = self.get_all_with_keys::<T>()?; let keyed_rows = self.get_all_with_keys::<T>()?;
let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip();
@ -2900,6 +3028,7 @@ impl CultCache {
pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> { pub fn put<T: DatabaseEntry>(&mut self, key: impl Into<String>, value: &T) -> Result<T> {
let (entry, parsed) = self.prepare_entry(key, value)?; let (entry, parsed) = self.prepare_entry(key, value)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
if let Some(home) = self.home_index(T::TYPE)? { if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?; self.stores[home].store.push(&entry)?;
} }
@ -3011,6 +3140,7 @@ impl CultCache {
for entry in &staged { for entry in &staged {
next.insert(entry_id(entry), entry.clone()); next.insert(entry_id(entry), entry.clone());
} }
self.refuse_second_globals(&next)?;
if let Some(store_index) = store_index { if let Some(store_index) = store_index {
// The home store receives its own records only: those it already // The home store receives its own records only: those it already
// holds in this cache plus the staged ones. // holds in this cache plus the staged ones.
@ -3029,6 +3159,7 @@ impl CultCache {
pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> { pub fn put_envelope<T: DatabaseEntry>(&mut self, entry: CultCacheEnvelope) -> Result<T> {
let parsed = self.validate_envelope::<T>(&entry)?; let parsed = self.validate_envelope::<T>(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
if let Some(home) = self.home_index(T::TYPE)? { if let Some(home) = self.home_index(T::TYPE)? {
self.stores[home].store.push(&entry)?; self.stores[home].store.push(&entry)?;
} }
@ -3061,6 +3192,7 @@ impl CultCache {
)); ));
} }
require_payload_depth(&entry)?; require_payload_depth(&entry)?;
self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?;
if let Some(home) = self.home_index(&entry.r#type)? { if let Some(home) = self.home_index(&entry.r#type)? {
self.stores[home].store.push(&entry)?; self.stores[home].store.push(&entry)?;
} }
@ -3146,10 +3278,43 @@ impl CultCache {
self.home_index(&entry.r#type)?; self.home_index(&entry.r#type)?;
loaded.insert(entry_id(&entry), entry); loaded.insert(entry_id(&entry), entry);
} }
self.refuse_second_globals(&loaded)?;
self.entries = loaded; self.entries = loaded;
Ok(()) Ok(())
} }
fn refuse_second_global(
&self,
entries: &BTreeMap<(String, String), CultCacheEnvelope>,
type_id: &str,
key: &str,
) -> Result<()> {
if !self.globals.contains(type_id) {
return Ok(());
}
let held_other = entries
.range((type_id.to_string(), String::new())..)
.take_while(|((held_type, _), _)| held_type == type_id)
.map(|((_, held_key), _)| held_key)
.find(|held_key| held_key.as_str() != key);
match held_other {
Some(existing_key) => Err(CultCacheSecondGlobal {
schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(),
existing_key: existing_key.clone(),
key: key.to_string(),
}
.into()),
None => Ok(()),
}
}
fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> {
for (type_id, key) in entries.keys() {
self.refuse_second_global(entries, type_id, key)?;
}
Ok(())
}
fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> { fn require_entry_type<T: DatabaseEntry>(&self) -> Result<()> {
match self.definitions.get(T::TYPE) { match self.definitions.get(T::TYPE) {
Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()),

View file

@ -0,0 +1,352 @@
//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal`
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
use cultcache_rs::{
CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry,
SingleFileMessagePackBackingStore,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "item")]
struct Item {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
#[cultcache(key = 3, default)]
value: u32,
}
fn item(name: &str, category: &str, code: Option<&str>) -> Item {
Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 }
}
/// Same names and values as `Item`, a different type: lookups never cross types.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "gadget")]
struct Gadget {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
category: String,
}
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "zed")]
struct Zed {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
#[cultcache(key = 3, default)]
value: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", global)]
struct Settings {
#[cultcache(key = 0)]
value: String,
}
fn settings(value: &str) -> Settings {
Settings { value: value.into() }
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "plain")]
struct Plain {
#[cultcache(key = 0)]
value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "numbered")]
struct Numbered {
#[cultcache(key = 0, name)]
id: u32,
#[cultcache(key = 1, index)]
owner: u64,
}
fn cache() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Item>().unwrap();
cache.register_entry_type::<Gadget>().unwrap();
cache.register_entry_type::<Zed>().unwrap();
cache.register_entry_type::<Settings>().unwrap();
cache.register_entry_type::<Plain>().unwrap();
cache.register_entry_type::<Numbered>().unwrap();
cache
}
fn reopened(path: &std::path::Path) -> CultCache {
let mut reopened = cache();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
reopened
}
fn stored_cache(path: &std::path::Path) -> CultCache {
let mut stored = cache();
stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
stored.pull_all_backing_stores().unwrap();
stored
}
// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache.
#[test]
fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lookups.cc");
let mut written = stored_cache(&path);
written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap();
let reopened = reopened(&path);
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
assert_eq!(by_name.value, 50);
assert_eq!(reopened.get_by_index::<Item>("category", "Consumable").unwrap(), Some(by_name.clone()));
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
}
#[test]
fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
let mut cache = cache();
cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap();
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
assert_eq!(cache.get_by_index::<Item>("category", "other").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "cat").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
let bare = CultCache::new();
assert!(bare.get_by_name::<Item>("A").is_err());
assert!(bare.get_by_index::<Item>("category", "cat").is_err());
assert!(bare.get_global::<Settings>().is_err());
}
#[test]
fn an_index_without_an_alias_is_named_by_its_member() {
let mut cache = cache();
cache.put("item:a", &item("A", "cat", None)).unwrap();
assert!(cache.get_by_index::<Item>("category", "cat").unwrap().is_some());
}
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
#[test]
fn blank_and_nil_values_are_not_found() {
let mut cache = cache();
cache.put("item:blank", &item("", " ", None)).unwrap();
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", " ").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
cache.put("item:space", &item(" ", "x", None)).unwrap();
assert_eq!(cache.get_by_name::<Item>(" ").unwrap().unwrap().category, "x");
}
#[test]
fn lookups_never_cross_types() {
let mut cache = cache();
cache.put("item:a", &item("same", "same", None)).unwrap();
cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap();
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "same").unwrap(), None);
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
assert!(cache.get_by_index::<Gadget>("category", "same").unwrap().is_some());
}
#[test]
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
let mut cache = cache();
cache.put("item:a", &item("old", "c1", None)).unwrap();
cache.put("item:a", &item("new", "c2", None)).unwrap();
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
assert_eq!(cache.get_by_index::<Item>("category", "c1").unwrap(), None, "a stale value is not found");
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
assert!(cache.get_by_index::<Item>("category", "c2").unwrap().is_some());
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "c2").unwrap(), None);
}
#[test]
fn non_string_members_are_found_by_their_text() {
let mut cache = cache();
let owner = 161_u64;
cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap();
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
assert!(cache.get_by_index::<Numbered>("owner", "161").unwrap().is_some());
}
// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with
// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other.
#[test]
fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shared.cc");
{
let mut cache = stored_cache(&path);
cache.put("item:p2", &item("p2", "x", None)).unwrap();
cache.put("item:p1", &item("p1", "x", None)).unwrap();
let found = |cache: &CultCache| cache.get_by_index::<Item>("category", "x").unwrap().map(|found| found.name);
assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second");
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap().unwrap().name, "p1");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one");
cache.put("item:p1", &item("p1", "x", None)).unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"));
cache.delete::<Item>("item:p2").unwrap();
assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing");
cache.delete::<Item>("item:p1").unwrap();
assert_eq!(found(&cache), None);
}
assert_eq!(reopened(&path).get_by_index::<Item>("category", "x").unwrap(), None);
}
#[test]
fn records_sharing_a_name_find_the_smallest_key() {
let mut cache = cache();
cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap();
cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap();
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 1);
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 2);
}
// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen.
#[test]
fn a_global_is_none_until_written_and_survives_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("global.cc");
{
let mut seed = stored_cache(&path);
assert_eq!(seed.get_global::<Settings>().unwrap(), None, "opening a store invented a global");
seed.put("global:settings", &settings("durable authority")).unwrap();
assert_eq!(seed.get_global::<Settings>().unwrap(), Some(settings("durable authority")));
}
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
}
#[test]
fn a_type_that_is_not_a_global_has_no_global() {
let mut cache = cache();
cache.put("plain:one", &Plain { value: "v".into() }).unwrap();
assert_eq!(cache.get_global::<Plain>().unwrap(), None);
}
#[test]
fn a_second_global_under_another_key_is_refused_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("second.cc");
let mut cache = stored_cache(&path);
cache.put("g1", &settings("one")).unwrap();
let refusal = cache.put("g2", &settings("two")).unwrap_err();
let second = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2"));
assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second."));
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("one")), "nothing reached the store");
// The same key is the same record, not a second.
cache.put("g1", &settings("one again")).unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one again")));
}
// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key.
#[test]
fn removing_a_global_frees_its_slot() {
let mut cache = cache();
cache.put("g1", &settings("one")).unwrap();
assert!(cache.put("g2", &settings("two")).is_err());
cache.delete::<Settings>("g1").unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
cache.put("g2", &settings("two")).unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("two")));
}
#[test]
fn every_write_path_refuses_a_second_global() {
let mut cache = cache();
cache.put("g1", &settings("one")).unwrap();
let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
assert!(cache.put_envelope::<Settings>(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap();
assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(cache.get::<Plain>("plain:1").unwrap(), None, "a refused batch lands none of it");
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
}
#[test]
fn a_batch_that_stages_two_globals_is_refused_whole() {
let mut cache = cache();
let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap();
let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
}
// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them.
#[test]
fn loading_a_store_that_holds_two_globals_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("two.cc");
let source = cache();
let mut store = SingleFileMessagePackBackingStore::new(&path);
for (key, value) in [("g1", "one"), ("g2", "two")] {
let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap();
store.push(&entry).unwrap();
}
let mut loading = cache();
loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
}
// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two
// records of a global type is refused whole and the prior view survives.
#[test]
fn loading_an_soa_table_that_holds_two_globals_is_refused() {
let source = cache();
let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap();
let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap();
let mut loading = cache();
loading.put("g0", &settings("prior")).unwrap();
loading.put("plain:1", &Plain { value: "prior".into() }).unwrap();
let refusal = loading
.load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second]))
.unwrap_err();
let second_global = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()];
named.sort();
assert_eq!(named, ["g1", "g2"], "the refusal names both records");
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("prior")), "a refused load keeps the prior view");
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), Some(Plain { value: "prior".into() }));
// One global in the table is the rule's own case, not a second.
loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap();
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("one")));
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), None, "a load replaces the view");
}

View file

@ -3,27 +3,26 @@
All notable changes to `@gamecult/cultcache-ts` are recorded here, following All notable changes to `@gamecult/cultcache-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## [0.15.0]
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
### Breaking ### Breaking
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name. - A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`. - A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store. - A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
### Added ### Added
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader. - `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity. - `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
- The document payload codec, exported from `@gamecult/cultcache-ts/document`: `decodeDocumentPayload`, `encodeDocumentPayload`, and the two named byte escapes `documentPayloadFromBytes` and `documentPayloadBytes`.
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`). - `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store. - `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store.
- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses. - A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses.
## [0.14.2]
### Added
- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`, - `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`,
interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows). interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows).
`push` and `delete` read the current file, merge and replace it under the lock, so concurrent `push` and `delete` read the current file, merge and replace it under the lock, so concurrent
@ -33,27 +32,18 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
is, but C# writers can still overwrite records a concurrent TypeScript writer added. is, but C# writers can still overwrite records a concurrent TypeScript writer added.
- The staged file is fsynced before the replace, and on Unix the directory after it. A - The staged file is fsynced before the replace, and on Unix the directory after it. A
directory sync failure after the replace is reported as replaced but not durable. directory sync failure after the replace is reported as replaced but not durable.
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
(`cultnet-ts/content`) bound payload nesting with the same check the cache
applies on decode.
### Changed ### Changed
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers - The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock. apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken - Opening a legacy store that needs healing rewrites it under the lock, from a read taken
under the lock. under the lock.
### Fixed
- A full `pushAll` reads the store's header under the lock first and refuses a store format
this runtime does not read (the same error a read gives), leaving the file as it was. A
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
that are not UTF-8 is refused by a whole write as a read refuses it.
### Dependencies ### Dependencies
- `koffi` 3.3.0, loaded only on the first write. - `koffi` 3.3.0, loaded only on the first write.
## [0.14.1]
### Added
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
(`cultnet-ts/content`) bound payload nesting with the same check the cache
applies on decode.

View file

@ -61,7 +61,7 @@ const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed No
const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload
``` ```
Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`.
## `.cc` Inspection ## `.cc` Inspection

View file

@ -1,15 +1,21 @@
{ {
"name": "@gamecult/cultcache-ts", "name": "@gamecult/cultcache-ts",
"version": "0.14.2", "version": "0.15.0",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"browser": "./dist/browser.js",
"require": "./dist/index.js", "require": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
}, },
"./browser": {
"types": "./dist/browser.d.ts",
"require": "./dist/browser.js",
"default": "./dist/browser.js"
},
"./document": { "./document": {
"types": "./dist/document.d.ts", "types": "./dist/document.d.ts",
"require": "./dist/document.js", "require": "./dist/document.js",
@ -25,6 +31,9 @@
"*": { "*": {
"document": [ "document": [
"dist/document.d.ts" "dist/document.d.ts"
],
"browser": [
"dist/browser.d.ts"
] ]
} }
}, },

View file

@ -0,0 +1,49 @@
// Browser-safe surface: everything the package root exports except the node-only single-file store
// (node:fs, koffi). Bring your own CacheBackingStore, or none.
export * as codecs from "./codec";
export type { Codec, SlotsCodec } from "./codec";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export {
decodeDocumentPayload,
defineDocumentRegistry,
defineDocumentType,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
schemaIdentityOf,
type CultCacheSchemaIdentity,
} from "./document";
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
export type { DocumentPayload } from "./document";
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
export type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFieldName,
CultCacheDocumentFormatter,
CultCacheDocumentIndexDefinition,
CultCacheDocumentRegistry,
CultCacheSchemaCatalogEntry,
CultCacheSchemaCatalogMember,
CultCacheSchema,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheIndexScalar,
InferCultCacheSchemaValue,
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";
export type {
CultCacheInspection,
InspectedCatalogEntry,
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";
export {
defineSwarmDocuments,
type DefineDocumentType,
type SwarmDocumentCatalog,
} from "./swarm-documents";

View file

@ -1,48 +1,2 @@
export * as codecs from "./codec"; export * from "./browser";
export type { Codec, SlotsCodec } from "./codec";
export { CultCache, CultCacheBuilder } from "./cult-cache";
export { inspectCultCacheBytes } from "./cult-cache-inspector";
export {
decodeDocumentPayload,
defineDocumentRegistry,
defineDocumentType,
documentPayloadBytes,
documentPayloadFromBytes,
encodeDocumentPayload,
schemaIdentityOf,
type CultCacheSchemaIdentity,
} from "./document";
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
export type { DocumentPayload } from "./document";
export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store";
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
export type {
AnyCultCacheDocumentDefinition,
CacheBackingStore,
CultCacheDocumentAccessor,
CultCacheDocumentDefinition,
CultCacheDocumentFieldName,
CultCacheDocumentFormatter,
CultCacheDocumentIndexDefinition,
CultCacheDocumentRegistry,
CultCacheSchemaCatalogEntry,
CultCacheSchemaCatalogMember,
CultCacheSchema,
CultCacheDocumentValue,
CultCacheEnvelope,
CultCacheIndexScalar,
InferCultCacheSchemaValue,
CultCacheStoreRegistration,
PushAllOptions,
} from "./types";
export type {
CultCacheInspection,
InspectedCatalogEntry,
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";
export {
defineSwarmDocuments,
type DefineDocumentType,
type SwarmDocumentCatalog,
} from "./swarm-documents";

View file

@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { join } from "node:path";
import test from "node:test";
import { runInNewContext } from "node:vm";
import { build } from "esbuild";
// dist-test/test/ -> dist-test/src/
const srcDir = join(__dirname, "..", "src");
async function bundleForBrowser(entry: string): Promise<string> {
const result = await build({
entryPoints: [join(srcDir, entry)],
bundle: true,
platform: "browser",
format: "iife",
globalName: "cultcache",
write: false,
logLevel: "silent",
});
return result.outputFiles[0].text;
}
test("the browser entry bundles for platform=browser and runs without node", async () => {
const code = await bundleForBrowser("browser.js");
assert.ok(!/node:fs|koffi/.test(code), "no node-only store in the browser bundle");
// A context with no require/process/Buffer: only what a browser page has.
const sandbox: Record<string, unknown> = { TextEncoder, TextDecoder };
runInNewContext(code + "\nglobalThis.__api = cultcache;", sandbox);
const api = sandbox.__api as Record<string, unknown>;
assert.equal(typeof api.CultCache, "function");
assert.equal(typeof api.defineDocumentType, "function");
assert.equal(api.SingleFileMessagePackBackingStore, undefined);
});
test("the node entry still exports the single-file store and everything the browser entry has", async () => {
const node = await import(join(srcDir, "index.js"));
const browser = await import(join(srcDir, "browser.js"));
assert.equal(typeof node.SingleFileMessagePackBackingStore, "function");
assert.equal(typeof node.readSingleFileStore, "function");
for (const name of Object.keys(browser)) assert.ok(name in node, `node entry lost ${name}`);
});
test("the package root keeps the node-only store out of the browser build", async () => {
await assert.rejects(bundleForBrowser("index.js"), /node:fs|node:path|koffi/);
});

View file

@ -1,11 +1,15 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import test from "node:test"; import test from "node:test";
// The TypeScript first-call snippet that docs/start/README.md shows: define a typed document, store it
// in a .cc file, read it back through a second cache. Keep it small; it is the front door. The docs show
// the region between the `snippet` markers verbatim, except that the package import reads
// "@gamecult/cultcache-ts" where this file reads "../src/index"; the docs test below fails when they differ.
// snippet:begin
import { z } from "zod"; import { z } from "zod";
import { import {
CultCache, CultCache,
SingleFileMessagePackBackingStore, SingleFileMessagePackBackingStore,
@ -13,8 +17,6 @@ import {
defineDocumentType, defineDocumentType,
} from "../src/index"; } from "../src/index";
// The TypeScript first-call snippet that docs/start/README.md links: define a typed document,
// store it in a .cc file, read it back through a second cache. Keep it small; it is the front door.
const itemDocument = defineDocumentType({ const itemDocument = defineDocumentType({
type: "item", type: "item",
schemaId: "gamecult.first_call_item.v1", schemaId: "gamecult.first_call_item.v1",
@ -26,26 +28,43 @@ const itemDocument = defineDocumentType({
}); });
const registry = defineDocumentRegistry(itemDocument); const registry = defineDocumentRegistry(itemDocument);
async function firstCall(path: string) {
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });
const reopened = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await reopened.pullAllBackingStores();
const potion = reopened.get(itemDocument, "item:potion");
const byName = reopened.getByName(itemDocument, "Potion");
return { potion, byName };
}
// snippet:end
test("define, store and read back a typed document", async () => { test("define, store and read back a typed document", async () => {
const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-"));
const path = join(dir, "first-call.cc");
try { try {
const cache = CultCache.builder() const { potion, byName } = await firstCall(join(dir, "first-call.cc"));
.withRegistry(registry) assert.equal(potion?.value, 50);
.withGenericStore(new SingleFileMessagePackBackingStore(path)) assert.equal(byName?.category, "Consumable");
.build();
await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });
const reopened = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await reopened.pullAllBackingStores();
assert.equal(reopened.get(itemDocument, "item:potion")?.value, 50);
assert.equal(reopened.getByName(itemDocument, "Potion")?.category, "Consumable");
} finally { } finally {
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
} }
}); });
test("docs show the tested snippet", async () => {
// Compiled to dist-test/test/first-call.test.js, so the package root is two levels up.
const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8");
const snippet = source
.split("// snippet:begin\n")[1]
.split("// snippet:end\n")[0]
.replace('"../src/index"', '"@gamecult/cultcache-ts"');
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
});

View file

@ -29,7 +29,8 @@ shader semantics.
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`, `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`,
`erfinv` (scalar only), exponential `erfinv` (scalar only), exponential
`decay`/`damp`, intercept/segment helpers (`distance_to_segment` in 2D and 3D, with and without `out closest`, and `segment_closest`), Catmull-Rom, Bezier curves, and `decay`/`damp`, intercept/segment helpers (`distance_to_segment` in 2D and 3D, with and without `out closest`, and `segment_closest`), Catmull-Rom, Bezier curves, and
deterministic value-noise primitives deterministic value-noise primitives, and `fbm(p)` / `fbm(p, octaves, lacunarity, gain)`, the value-only octave sum
of `snoise` (`cultmath_fbm` in HLSL and GLSL)
- Site cells: `power_cells` and `multiplicative_cells` return the two nearest sites of a point as `CultSiteCells`, - Site cells: `power_cells` and `multiplicative_cells` return the two nearest sites of a point as `CultSiteCells`,
`soft_gabriel_gate` gates a pair of sites by the empty-disc margin. Each is a loop over the shader steps `soft_gabriel_gate` gates a pair of sites by the empty-disc margin. Each is a loop over the shader steps
`cultmath_power_score` / `cultmath_multiplicative_score`, `cultmath_site_cells_empty`, `cultmath_site_cells_insert`, `cultmath_power_score` / `cultmath_multiplicative_score`, `cultmath_site_cells_empty`, `cultmath_site_cells_insert`,

View file

@ -451,6 +451,26 @@ together, laid out as `float4(gradient.xyz, value.w)`, with no value-only twin
nearly cancels. The total weight `W` is positive for every `pcg3d` input (the nearly cancels. The total weight `W` is positive for every `pcg3d` input (the
smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs smallest found by hill-climbing is 0.056); `W = 0`, and a NaN output, needs
adversarial jitter no hash yields. adversarial jitter no hash yields.
Lipschitz bound of the `(cosine, sine)` field in `p` (one cell is 1 unit): the
largest singular value of its 2×3 Jacobian is at most
`max(10, 1.25 |side|) / (1 - normalization)` for `normalization` in `[0, 0.9]` and
`|side|` in `[0.25, 25]`. A scalar use of `cosine` or `sine` alone is bounded by the same number (its gradient norm is
one row of the Jacobian, never above the largest singular value). Method (`tools/phacelle-lipschitz`):
random `p` (a 64-unit cube), a uniformly random `side` direction and offset, the
exact gradient at each, 4 million samples per (normalization, |side|); then
hill-climbing from the 256 best samples over `p`, `side` direction and offset
(10 000 steps each); then value differences around the climbed argmax to confirm the
gradient is the field's slope (agreement within 3%). Random sampling alone
undershoots the climbed maximum by up to 4×, because the maxima sit at near-cancellation
points of the blend (`1/|I|`); quote the climbed number. The climbed sup is 0.016 to 0.88 of
the envelope over the sweep: tightest (within 1.15×) at `normalization` 0 and `|side| >= 8`,
and 1.5 to 60× loose at small `|side|` or large `normalization`, where the
envelope's constant 10 is the worst case over all `|side|`. The typical gradient (99.99th percentile of the
samples) is 2 to 5× below the sup, so a sphere trace steps safely at `distance / L`
and can step farther with the returned gradient. The bound is not a proof (a
search found it, not an argument) and it does not hold as `normalization` approaches 1:
the 1/|I| factor is unbounded there (sampled 1335 at `normalization` 1, `|side|` 2π, and
rising with the sample count). If the caller scales `p` by `f` the bound scales by `f`.
Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least Cells whose per-axis lower bound `Σ max(0, |local - g| - 0.5)²` is at least
2.25 lie outside the support and have weight exactly 0, so they are skipped, 2.25 lie outside the support and have weight exactly 0, so they are skipped,
which changes no output bit. Cells whose bound sits just under 2.25 can still which changes no output bit. Cells whose bound sits just under 2.25 can still

View file

@ -342,6 +342,26 @@ vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain)
return vec4(gradient, value); return vec4(gradient, value);
} }
// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves,
// lacunarity 2, gain 0.5.
float cultmath_fbm(vec3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
for (int i = 0; i < octaves; i++)
{
value += amplitude * cultmath_snoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return value;
}
float cultmath_fbm(vec3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); }
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad
// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped.
vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain)

View file

@ -342,6 +342,26 @@ float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
return float4(gradient, value); return float4(gradient, value);
} }
// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves,
// lacunarity 2, gain 0.5.
float cultmath_fbm(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
for (int i = 0; i < octaves; i++)
{
value += amplitude * cultmath_snoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return value;
}
float cultmath_fbm(float3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); }
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad
// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped.
float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain)

View file

@ -823,6 +823,29 @@ public static partial class math
return new float4(gradient, value); return new float4(gradient, value);
} }
// Value-only fractional Brownian motion: the octave sum of snoise, octave i at frequency lacunarity^i and
// amplitude gain^i. It is fbm_grad(p, octaves, lacunarity, gain).w without the gradient work (to float
// rounding: snoise and snoise_grad.w order their arithmetic differently), so a loop that only wants the height
// (a sphere trace) does not pay for the gradient. octaves clamps to [0, 16] as fbm_grad's does. The one-argument
// form is the stock four octaves, lacunarity 2, gain 1/2: its range is within +-1.875 times snoise's.
public static float fbm(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
var amplitude = 1.0f;
var frequency = 1.0f;
var value = 0.0f;
for (var i = 0; i < octaves; i++)
{
value += amplitude * snoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return value;
}
public static float fbm(float3 value) => fbm(value, 4, 2.0f, 0.5f);
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term, // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16; the operator's dune term,
// 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient = // 2026-09-25): each octave folds snoise_grad about zero, value = Sum a_i*(1 - |n_i|), gradient =
// -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain // -Sum a_i*f_i*sign(n_i)*grad(n_i), the same per-octave frequency scaling as fbm_grad's chain

View file

@ -19,11 +19,23 @@ namespace CultMath.Tests;
/// </summary> /// </summary>
public sealed class CyclesOracleTests public sealed class CyclesOracleTests
{ {
// The ulp distance a function is allowed from the reference. Empty: the fixtures are the SSE4.2 kernel's results // The ulp distance a function is allowed from the reference; a function not named is held to 0. The fixtures are
// and every function equals them, power and logarithm included (glibc's powf and logf, 0 ulp from MathF on Linux). // the SSE4.2 kernel's results and almost every function equals them, power and logarithm included (glibc's powf and
// logf, 0 ulp from MathF on Linux). asin, acos and atan2 are the exception: the fixtures' glibc and the libm these
// tests run on round them one ulp apart (glsl-parity.json families 95-97 are the same three, platform families
// there too), so each is allowed 1 ulp, never more (TolerancesAreCappedAtOneUlpAndStayOnTheDriftingFunctions).
// An entry needs a measured case on the reference named in it (a failing case's bits and ulp); other builds' // An entry needs a measured case on the reference named in it (a failing case's bits and ulp); other builds'
// tolerances belong to the class table in tools/cycles-oracle/README.md, not here. // tolerances belong to the class table in tools/cycles-oracle/README.md, not here.
private static readonly Dictionary<string, (int Ulp, string Measured)> Tolerances = new(); private const int MaxToleratedUlp = 1;
private static readonly Dictionary<string, (int Ulp, string Measured)> Tolerances = new()
{
["cycles_arcsine"] = (1, "case 3f4dd9af -> 3f6f256c, C# 3f6f256d"),
["cycles_arccosine"] = (1, "case 3f027192 -> 3f84a03c, C# 3f84a03b"),
["cycles_arctan2"] = (1, "case 3f000000 bdcccccd -> 3fe2541c, C# 3fe2541d"),
};
private static int Allowed(string function) => Tolerances.TryGetValue(function, out var tolerance) ? tolerance.Ulp : 0;
private const string CSharp = "C#"; private const string CSharp = "C#";
private const string Mirror = "HLSL mirror"; private const string Mirror = "HLSL mirror";
@ -120,7 +132,7 @@ public sealed class CyclesOracleTests
var name = (string)entry!["function"]!; var name = (string)entry!["function"]!;
var method = Method(name); var method = Method(name);
var clamped = (string?)entry["then"] is not null; var clamped = (string?)entry["then"] is not null;
var allowed = Tolerances.TryGetValue(name, out var tolerance) ? tolerance.Ulp : 0; var allowed = Allowed(name);
var worst = 0L; var worst = 0L;
string? worstCase = null; string? worstCase = null;
var cases = entry["cases"]!.AsArray(); var cases = entry["cases"]!.AsArray();
@ -158,6 +170,24 @@ public sealed class CyclesOracleTests
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures)); Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
} }
[Fact]
public void TolerancesAreCappedAtOneUlpAndStayOnTheDriftingFunctions()
{
Assert.Equal(new[] { "cycles_arccosine", "cycles_arcsine", "cycles_arctan2" }, Tolerances.Keys.Order().ToArray());
Assert.All(Tolerances.Values, t => Assert.InRange(t.Ulp, 1, MaxToleratedUlp));
Assert.All(Tolerances.Values, t => Assert.NotEmpty(t.Measured));
// A result two ulps from the reference is past every allowance, and one ulp is within the drifting functions' only.
foreach (var function in Tolerances.Keys.Append("cycles_power"))
{
var want = 0.7f;
var twoAway = MathF.BitIncrement(MathF.BitIncrement(want));
Assert.Equal(2, Distance(want, twoAway));
Assert.True(Distance(want, twoAway) > Allowed(function), $"{function}: a 2 ulp miss would pass");
Assert.Equal(function != "cycles_power", Distance(want, MathF.BitIncrement(want)) <= Allowed(function));
}
}
private static JsonNode RampEntry(string interpolation) => private static JsonNode RampEntry(string interpolation) =>
Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!; Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!;

View file

@ -0,0 +1,73 @@
using System;
using CultMath;
using Xunit;
namespace CultMath.Tests;
public sealed class FbmTests
{
private static float3 Point(System.Random r) => new(r.NextSingle() * 20.0f - 10.0f, r.NextSingle() * 20.0f - 10.0f, r.NextSingle() * 20.0f - 10.0f);
[Fact]
public void OneOctaveIsSnoise()
{
var random = new System.Random(0xFB01);
for (var i = 0; i < 64; i++)
{
var p = Point(random);
Assert.Equal(math.snoise(p), math.fbm(p, 1, 1.0f + random.NextSingle() * 3.0f, 0.1f + random.NextSingle() * 0.8f));
}
}
[Fact]
public void ZeroOrNegativeOctavesSumNothing()
{
var p = new float3(0.3f, 1.7f, -2.2f);
Assert.Equal(0.0f, math.fbm(p, 0, 2.0f, 0.5f));
Assert.Equal(0.0f, math.fbm(p, -5, 2.0f, 0.5f));
}
[Fact]
public void OctavesCompoundGainAndLacunarityIndependently()
{
// gain != lacunarity and neither is 1, so applying either once, or swapping them, changes the sum.
const float lacunarity = 2.3f;
const float gain = 0.4f;
var random = new System.Random(0xFB02);
for (var i = 0; i < 64; i++)
{
var p = Point(random);
var expected = math.snoise(p) + gain * math.snoise(p * lacunarity) + gain * gain * math.snoise(p * (lacunarity * lacunarity));
Assert.Equal(expected, math.fbm(p, 3, lacunarity, gain), 5);
}
}
[Fact]
public void ValueIsFbmGradsValue()
{
var random = new System.Random(0xFB03);
for (var i = 0; i < 128; i++)
{
var p = Point(random);
Assert.Equal(math.fbm_grad(p, 5, 2.0f, 0.55f).w, math.fbm(p, 5, 2.0f, 0.55f), 4);
}
}
[Fact]
public void OctaveCountClampsToSixteenLikeFbmGrad()
{
var p = new float3(0.3f, 1.7f, -2.2f);
Assert.Equal(math.fbm(p, 16, 1.1f, 0.9f), math.fbm(p, 1000, 1.1f, 0.9f));
}
[Fact]
public void OneArgumentFormIsFourOctavesLacunarityTwoGainHalf()
{
var random = new System.Random(0xFB04);
for (var i = 0; i < 64; i++)
{
var p = Point(random);
Assert.Equal(math.fbm(p, 4, 2.0f, 0.5f), math.fbm(p));
}
}
}

View file

@ -904,6 +904,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
// The site-array functions: a fixed-capacity array and a count, killed on the device (the lattice at rings up to 17). // The site-array functions: a fixed-capacity array and a count, killed on the device (the lattice at rings up to 17).
new Family($"vec4 cultmath_power_border(vec3, vec4[{SiteArrays.Capacity}], int)", "ulp-bounded", SiteArrays.PowerBorderArguments), new Family($"vec4 cultmath_power_border(vec3, vec4[{SiteArrays.Capacity}], int)", "ulp-bounded", SiteArrays.PowerBorderArguments),
new Family($"vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[{SiteArrays.Capacity}], int, float, float, float, float, float, vec2, float)", "ulp-bounded", SiteArrays.LatticeArguments), new Family($"vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[{SiteArrays.Capacity}], int, float, float, float, float, float, vec2, float)", "ulp-bounded", SiteArrays.LatticeArguments),
new Family("float cultmath_fbm(vec3, int, float, float)", "ulp-bounded", r => new object[] { Point3(r), 4, 2.0f, 0.5f }),
}).ToArray(); }).ToArray();
// A point of magnitude m, m log-uniform in [lo, hi]: one component at +-m, the other two uniform in [-m, m], // A point of magnitude m, m log-uniform in [lo, hi]: one component at +-m, the other two uniform in [-m, m],
@ -974,6 +975,7 @@ static int4 clamp(int4 x, int4 a, int4 b) { if (a.x > b.x || a.y > b.y || a.z >
var json = new StringBuilder(); var json = new StringBuilder();
json.Append("{\n"); json.Append("{\n");
json.Append(" \"generator\": \"GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1\",\n"); json.Append(" \"generator\": \"GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1\",\n");
json.Append(" \"recorded_on\": \"platform families: Yggdrasil's mcr.microsoft.com/dotnet/sdk:10.0 image (Debian GNU/Linux 13 trixie, .NET 10.0.14); lavapipe device bounds: fixtures/device-bounds-lavapipe.txt (llvmpipe LLVM 20.1.2, mesa-vulkan-drivers 25.2.8-0ubuntu0.24.04.4, ubuntu:24.04 probe image)\",\n");
json.Append($" \"seed\": {Seed},\n"); json.Append($" \"seed\": {Seed},\n");
json.Append($" \"points\": {Points},\n"); json.Append($" \"points\": {Points},\n");
json.Append(" \"encoding\": \"each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order\",\n"); json.Append(" \"encoding\": \"each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order\",\n");

View file

@ -47,6 +47,8 @@ public sealed class HlslSourceCompatibilityTests
Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_hessian", typeof(float3))); Assert.NotNull(ShaderFunction(shader, "cultmath_snoise_hessian", typeof(float3)));
Assert.NotNull(ShaderFunction(shader, "cultmath_integral_clamp_linear", typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_integral_clamp_linear", typeof(float), typeof(float), typeof(float), typeof(float), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_fbm_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_fbm", typeof(float3), typeof(int), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_fbm", typeof(float3)));
Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_ridged_grad", typeof(float3), typeof(int), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_phacelle", typeof(float3), typeof(float3), typeof(float), typeof(float)));
Assert.NotNull(ShaderFunction(shader, "cultmath_iv_snoise_ball", typeof(float3), typeof(float))); Assert.NotNull(ShaderFunction(shader, "cultmath_iv_snoise_ball", typeof(float3), typeof(float)));
@ -205,7 +207,7 @@ public sealed class HlslSourceCompatibilityTests
Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}");
// The three site-array functions are compared by SiteArrayMirrorsMatchCSharp, which draws their counts and arrays. // The three site-array functions are compared by SiteArrayMirrorsMatchCSharp, which draws their counts and arrays.
Assert.Equal(140, compared.Count); Assert.Equal(141, compared.Count);
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,91 @@
using System;
using System.Linq;
using CultMath;
using Xunit;
namespace CultMath.Tests;
/// <summary>
/// A ring-buffer terrain (Voxel Space) fills height rows in whatever order the camera uncovers them, so a
/// Phacelle row must be a pure function of world x, z and the seed: the same row, bit for bit, whether the
/// rows around it were generated forward, backward or shuffled. C# phacelle mirrors cultmath_phacelle in
/// shaders/CultMath.Phacelle.glsl; GlslMirrorTests pins the two together, and this pins that neither keeps
/// state between calls.
/// </summary>
public sealed class PhacelleRowOrderTests
{
private const int Rows = 24;
private const int Columns = 48;
private const int Seed = 7919;
// One height sample: world (x, z) and the seed alone choose the point, the stripe direction and the phase.
private static int[] Sample(int worldX, int worldZ, int seed)
{
var p = new float3(worldX * 0.173f, (seed & 0xff) * 0.37f, worldZ * 0.173f);
var angle = (seed >> 8) * 0.61f;
var side = new float3(math.cos(angle), 0.0f, math.sin(angle)) * (math.TAU * 0.9f);
var phasor = math.phacelle(p, side, (seed & 0xf) / 16.0f, 0.5f);
return new[]
{
Bits(phasor.cosine.x), Bits(phasor.cosine.y), Bits(phasor.cosine.z), Bits(phasor.cosine.w),
Bits(phasor.sine.x), Bits(phasor.sine.y), Bits(phasor.sine.z), Bits(phasor.sine.w),
};
}
private static int Bits(float value) => BitConverter.SingleToInt32Bits(value);
private static int[][] Row(int worldZ, int seed) =>
Enumerable.Range(-Columns / 2, Columns).Select(x => Sample(x, worldZ, seed)).ToArray();
private static int[][][] Generate(int[] order, int seed)
{
var rows = new int[Rows][][];
foreach (var z in order) rows[z] = Row(z - Rows / 2, seed);
return rows;
}
private static void AssertSameRows(int[][][] expected, int[][][] actual)
{
for (var z = 0; z < Rows; z++)
for (var x = 0; x < Columns; x++)
Assert.True(expected[z][x].SequenceEqual(actual[z][x]), $"row {z} column {x} differs bit for bit");
}
[Fact]
public void RowsAreBitIdenticalForwardBackwardAndShuffled()
{
var forward = Enumerable.Range(0, Rows).ToArray();
var backward = forward.Reverse().ToArray();
var shuffled = (int[])forward.Clone();
var random = new System.Random(20261104);
for (var i = shuffled.Length - 1; i > 0; i--)
{
var j = random.Next(i + 1);
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
}
Assert.False(shuffled.SequenceEqual(forward) || shuffled.SequenceEqual(backward));
var reference = Generate(forward, Seed);
AssertSameRows(reference, Generate(backward, Seed));
AssertSameRows(reference, Generate(shuffled, Seed));
}
[Fact]
public void ARowIsTheSameWhenRegeneratedAfterAnyOtherSeedAndRow()
{
var first = Row(5, Seed);
_ = Row(-3, Seed + 1);
_ = Row(5, Seed + 1);
var again = Row(5, Seed);
for (var x = 0; x < Columns; x++) Assert.True(first[x].SequenceEqual(again[x]));
}
// Guards the test itself: a different seed or a different row must change the bits, or the equalities
// above would hold for a function that ignores its inputs.
[Fact]
public void RowsDependOnSeedAndWorldRow()
{
Assert.False(Row(5, Seed).SelectMany(s => s).SequenceEqual(Row(5, Seed + 1).SelectMany(s => s)));
Assert.False(Row(5, Seed).SelectMany(s => s).SequenceEqual(Row(6, Seed).SelectMany(s => s)));
}
}

View file

@ -337,4 +337,33 @@ public sealed class PhacelleTests
Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z)); Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z));
Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w)); Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w));
} }
// The Lipschitz envelope of the (cosine, sine) field in p, per docs/design.md ("Lipschitz bound"): the largest
// singular value of its 2x3 Jacobian never exceeds max(10, 1.25 |side|) / (1 - normalization), for normalization
// in [0, 0.9] and |side| in [0.25, 25]. tools/phacelle-lipschitz measures it by sampling and hill-climbing; this
// pins that no ordinary sample passes it (the climbed maximum is at most 0.88 of the envelope).
[Theory]
[InlineData(0.0f)]
[InlineData(0.5f)]
[InlineData(0.75f)]
[InlineData(0.9f)]
public void GradientNormStaysUnderTheDocumentedLipschitzEnvelope(float normalization)
{
var random = new System.Random(0x11B5);
foreach (var sideLength in new[] { 0.25f, 1.0f, 1.5f, 4.0f, 12.5f, 25.0f })
{
var envelope = Math.Max(10.0, 1.25 * sideLength) / (1.0 - normalization);
for (var i = 0; i < 20000; i++)
{
var p = RandomPoint(random, 32.0f);
var side = math.normalize(RandomSide(random)) * sideLength;
var phasor = math.phacelle(p, side, random.NextSingle(), normalization);
var a = (double)math.dot(phasor.cosine.xyz, phasor.cosine.xyz);
var d = (double)math.dot(phasor.sine.xyz, phasor.sine.xyz);
var b = (double)math.dot(phasor.cosine.xyz, phasor.sine.xyz);
var norm = Math.Sqrt((a + d) / 2.0 + Math.Sqrt(Math.Max(0.0, (a + d) * (a + d) / 4.0 - (a * d - b * b))));
Assert.True(norm <= envelope, $"|J| {norm} passes the envelope {envelope} at p {p}, side {side}, normalization {normalization}");
}
}
}
} }

View file

@ -96,9 +96,9 @@ lavapipe-glsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256
lavapipe-glsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b lavapipe-glsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b
lavapipe-glsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592 lavapipe-glsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592
lavapipe-glsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d lavapipe-glsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d
lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 lavapipe-glsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-glsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890 lavapipe-glsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890
lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 lavapipe-glsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-glsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 30 1358.4375 0 62bbafb8420ed5aaa0ce11d1470ca98945e690b186c33a1e7cf4d71478396465 lavapipe-glsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 30 1358.4375 0 62bbafb8420ed5aaa0ce11d1470ca98945e690b186c33a1e7cf4d71478396465
lavapipe-glsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 110 1317.4754224467295 0 0efd9b3a637b46033ed0351f64de274db4d9ce05bb83dd1ce57afb3ceaea26bc lavapipe-glsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 110 1317.4754224467295 0 0efd9b3a637b46033ed0351f64de274db4d9ce05bb83dd1ce57afb3ceaea26bc
lavapipe-glsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe lavapipe-glsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe
@ -126,6 +126,7 @@ lavapipe-glsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float
lavapipe-glsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740 lavapipe-glsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740
lavapipe-glsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d lavapipe-glsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d
lavapipe-glsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823 lavapipe-glsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823
lavapipe-glsl 125 float cultmath_fbm(vec3, int, float, float) ulp-bounded 43 190.375 0 8f852b55293bd75a4e2e64cd1d1f5083605c2ba2d0877cdf4e8e507c72890135
lavapipe-hlsl 0 float cultmath_snoise(vec3) ulp-bounded 94 122.6875 0 5d41c14241a13124f2e1b49f230f5df0c48d0d69fb1be9ef2f33dc1702bf0b0a lavapipe-hlsl 0 float cultmath_snoise(vec3) ulp-bounded 94 122.6875 0 5d41c14241a13124f2e1b49f230f5df0c48d0d69fb1be9ef2f33dc1702bf0b0a
lavapipe-hlsl 1 float cultmath_snoise(vec2) ulp-bounded 204 1 0 81d9c62726afdccbbe4ffef173b5c92731eb6bf5927b3979d653e50dbd5d4064 lavapipe-hlsl 1 float cultmath_snoise(vec2) ulp-bounded 204 1 0 81d9c62726afdccbbe4ffef173b5c92731eb6bf5927b3979d653e50dbd5d4064
lavapipe-hlsl 2 vec4 cultmath_snoise_grad(vec3) ulp-bounded 71 617.5 0 f7e9ed1707cdb015efaba0e0150193309da0d4c125349518f8b7c60a17b25790 lavapipe-hlsl 2 vec4 cultmath_snoise_grad(vec3) ulp-bounded 71 617.5 0 f7e9ed1707cdb015efaba0e0150193309da0d4c125349518f8b7c60a17b25790
@ -221,9 +222,9 @@ lavapipe-hlsl 91 vec3 cultmath_cycles_mix_val(float, vec3, vec3) ulp-bounded 256
lavapipe-hlsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b lavapipe-hlsl 92 vec3 cultmath_cycles_mix_color(float, vec3, vec3) ulp-bounded 256 0 0 00067b1567bcd6c07a81fa5002f145dfca22437a3f3df5389b2665334d14909b
lavapipe-hlsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592 lavapipe-hlsl 93 vec3 cultmath_cycles_mix_soft(float, vec3, vec3) ulp-bounded 157 1.9750388068880254 0 ed7c5508ca0543cc20a5f0c5624e75e3ca2a2d6044d481816eeda2edc50c0592
lavapipe-hlsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d lavapipe-hlsl 94 vec3 cultmath_cycles_mix_linear(float, vec3, vec3) ulp-bounded 256 0 0 672435cc30bcbbb253cce7316ee0712b1a18f06924bc89cea2d3388dd164a15d
lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 154 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585 lavapipe-hlsl 95 float cultmath_cycles_arcsine(float) ulp-bounded, platform asin 155 2734.286809678319 0 f28f3ed0a53ffeebdfa84865d2d3d41590e6b46fc1578d40e1215e2a9e1af585
lavapipe-hlsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890 lavapipe-hlsl 96 float cultmath_cycles_arccosine(float) ulp-bounded, platform acos 79 1046.8674409774287 0 22a2c38ab5a232cdbb33cf0c133362581a398f64a4d0d5d6dd89dc5af06bd890
lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 6 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4 lavapipe-hlsl 97 float cultmath_cycles_arctan2(float, float) ulp-bounded, platform atan 7 25.5 0 2c60447d80186f6dc9726f19c09fa96ec3f66d8f5688968da533d9195ea463c4
lavapipe-hlsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 33 2377.8799856935966 0 a64853839f394cc7c367ba5e107caa8bb396f07cf202263aa917095dd1c939f7 lavapipe-hlsl 98 mat3 cultmath_snoise_hessian(vec3) ulp-bounded 33 2377.8799856935966 0 a64853839f394cc7c367ba5e107caa8bb396f07cf202263aa917095dd1c939f7
lavapipe-hlsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 132 1317.4754224467295 0 9066a49e50731881583ffca16c949171bb61578a1dfd497c54dafe773ab49488 lavapipe-hlsl 99 mat3 cultmath_snoise_hessian(vec3) ulp-bounded, on the simplex tie set 132 1317.4754224467295 0 9066a49e50731881583ffca16c949171bb61578a1dfd497c54dafe773ab49488
lavapipe-hlsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe lavapipe-hlsl 100 float cultmath_integral_clamp_linear(float, float, float, float, float, float) ulp-bounded 256 0 0 84bf9c9a2126b3114a692507cc54f58d313afc33de8a1cac83969cc508433cbe
@ -251,3 +252,4 @@ lavapipe-hlsl 121 float cultmath_lattice_distance(vec2, vec2, float, vec2, float
lavapipe-hlsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740 lavapipe-hlsl 122 float cultmath_lattice_smooth_step(float, float, float, float) ulp-bounded 256 0 0 6cbeddde536b12e83873f2e5ac60d16bef22049d2a7dea61af0ff1e0d5b84740
lavapipe-hlsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d lavapipe-hlsl 123 vec4 cultmath_power_border(vec3, vec4[128], int) ulp-bounded 188 65.72121683891783 0 8427c2b99109a4f59ff745fdb87f261deb1cbae34ce99bc1ae05ebb7f9963b4d
lavapipe-hlsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823 lavapipe-hlsl 124 vec4 cultmath_weighted_lattice_cells(vec2, float, vec4[128], int, float, float, float, float, float, vec2, float) ulp-bounded 179 3 0 fd3b58c4e2ba8a7bf801a1e6cdff82468abcb368442bc98b2583fda9d2e6f823
lavapipe-hlsl 125 float cultmath_fbm(vec3, int, float, float) ulp-bounded 49 190.375 0 cd02f850f1d01ff77a3cb1c49af2bb49e51bb18c395936703cb37b7729ad2c88

View file

@ -1,5 +1,6 @@
{ {
"generator": "GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1", "generator": "GlslMirrorTests.GoldenFixtureMatchesCSharp (packages/cultmath/tests/CultMath.Tests); regenerate with CULTMATH_WRITE_GLSL=1",
"recorded_on": "platform families: Yggdrasil's mcr.microsoft.com/dotnet/sdk:10.0 image (Debian GNU/Linux 13 trixie, .NET 10.0.14); lavapipe device bounds: fixtures/device-bounds-lavapipe.txt (llvmpipe LLVM 20.1.2, mesa-vulkan-drivers 25.2.8-0ubuntu0.24.04.4, ubuntu:24.04 probe image)",
"seed": 20261002, "seed": 20261002,
"points": 256, "points": 256,
"encoding": "each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order", "encoding": "each case is 'arguments -> result', every scalar a float32 or 32-bit integer bit pattern in hex, vectors and structs flattened in field order",
@ -6820,7 +6821,7 @@
{ {
"glsl": "vec2 cultmath_iv_exp(vec2)", "glsl": "vec2 cultmath_iv_exp(vec2)",
"tolerance": "ulp-bounded, platform exp", "tolerance": "ulp-bounded, platform exp",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"41202b2a 4120314e -> 46ade79e 46ae2a6b", "41202b2a 4120314e -> 46ade79e 46ae2a6b",
"c0d1a016 bfb9db96 -> 3abb4cbc 3e6fb77f", "c0d1a016 bfb9db96 -> 3abb4cbc 3e6fb77f",
@ -7083,7 +7084,7 @@
{ {
"glsl": "CultPhasor cultmath_phacelle(vec3, vec3, float, float)", "glsl": "CultPhasor cultmath_phacelle(vec3, vec3, float, float)",
"tolerance": "ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1)", "tolerance": "ulp-bounded, platform exp, sin, cos; across platforms |diff| <= 2^-20 max(|v|, 1); on a device |diff| <= 2^-18 max(|v|, 1)",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"c01d8630 41b2cd8c 41ea274c bfa19894 40aeb6ce 40918978 3e9be843 3f000000 -> bf74d9fb 3fb71956 bf35fe4e 3f21280a c01277e2 403b5fe4 40464bbd bd5b4455", "c01d8630 41b2cd8c 41ea274c bfa19894 40aeb6ce 40918978 3e9be843 3f000000 -> bf74d9fb 3fb71956 bf35fe4e 3f21280a c01277e2 403b5fe4 40464bbd bd5b4455",
"c2234cf7 4237b112 419ae0c0 c0acbcd4 bf60f9a0 bfeb2b08 3ebf0a10 3f000000 -> c0d0048f 3f228da3 bfc85f60 3edaf41b c0171c80 be137f8c be1c92a3 bf5747bf", "c2234cf7 4237b112 419ae0c0 c0acbcd4 bf60f9a0 bfeb2b08 3ebf0a10 3f000000 -> c0d0048f 3f228da3 bfc85f60 3edaf41b c0171c80 be137f8c be1c92a3 bf5747bf",
@ -12591,7 +12592,7 @@
{ {
"glsl": "float cultmath_cycles_power(float, float)", "glsl": "float cultmath_cycles_power(float, float)",
"tolerance": "ulp-bounded, platform pow", "tolerance": "ulp-bounded, platform pow",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"bfb8bc1c 00000000 -> 3f800000", "bfb8bc1c 00000000 -> 3f800000",
"3f7fc090 3fa15ed0 -> 3f7fb009", "3f7fc090 3fa15ed0 -> 3f7fb009",
@ -12854,7 +12855,7 @@
{ {
"glsl": "float cultmath_cycles_logarithm(float, float)", "glsl": "float cultmath_cycles_logarithm(float, float)",
"tolerance": "ulp-bounded, platform log", "tolerance": "ulp-bounded, platform log",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"416175a4 418f2e6d -> 3f6ac789", "416175a4 418f2e6d -> 3f6ac789",
"40d2d3ad 416ecd13 -> 3f328da7", "40d2d3ad 416ecd13 -> 3f328da7",
@ -24907,7 +24908,7 @@
{ {
"glsl": "float cultmath_cycles_arcsine(float)", "glsl": "float cultmath_cycles_arcsine(float)",
"tolerance": "ulp-bounded, platform asin", "tolerance": "ulp-bounded, platform asin",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"3f74439c -> 3fa22776", "3f74439c -> 3fa22776",
"beaa06dc -> bead5166", "beaa06dc -> bead5166",
@ -24919,27 +24920,27 @@
"bf44c25b -> bf606b2a", "bf44c25b -> bf606b2a",
"be9326d8 -> be9541be", "be9326d8 -> be9541be",
"bf047a79 -> bf0b3e4e", "bf047a79 -> bf0b3e4e",
"3f6fb444 -> 3f9b2514", "3f6fb444 -> 3f9b2513",
"bec3bc1c -> bec8d8b0", "bec3bc1c -> bec8d8b0",
"3ed94bc8 -> 3ee069a8", "3ed94bc8 -> 3ee069a8",
"be732cb0 -> be75852c", "be732cb0 -> be75852c",
"bf525772 -> bf76dbfc", "bf525772 -> bf76dbfc",
"3f5f4508 -> 3f87a01e", "3f5f4508 -> 3f87a01e",
"3f5243a0 -> 3f76b93a", "3f5243a0 -> 3f76b939",
"3f6f8480 -> 3f9ae13d", "3f6f8480 -> 3f9ae13d",
"bf43af08 -> bf5ebe7a", "bf43af08 -> bf5ebe7a",
"3fb52362 -> 3fc90fdb", "3fb52362 -> 3fc90fdb",
"3eae8380 -> 3eb2152a", "3eae8380 -> 3eb2152a",
"bf3ff8fb -> bf590ffc", "bf3ff8fb -> bf590ffc",
"3f7bb06c -> 3fb189e5", "3f7bb06c -> 3fb189e5",
"3dee93e0 -> 3def1ede", "3dee93e0 -> 3def1edd",
"bfb0b96e -> bfc90fdb", "bfb0b96e -> bfc90fdb",
"bf454824 -> bf613cb1", "bf454824 -> bf613cb1",
"3f23fb40 -> 3f31f9c7", "3f23fb40 -> 3f31f9c7",
"be9b0000 -> be9d788d", "be9b0000 -> be9d788d",
"3fb7c110 -> 3fc90fdb", "3fb7c110 -> 3fc90fdb",
"bf0c30e4 -> bf145b51", "bf0c30e4 -> bf145b51",
"3e855c0c -> 3e86ea5c", "3e855c0c -> 3e86ea5d",
"bf0e2123 -> bf16ae2c", "bf0e2123 -> bf16ae2c",
"3f72c708 -> 3f9fbe32", "3f72c708 -> 3f9fbe32",
"3f98afd8 -> 3fc90fdb", "3f98afd8 -> 3fc90fdb",
@ -24970,7 +24971,7 @@
"3f81d10a -> 3fc90fdb", "3f81d10a -> 3fc90fdb",
"bf033714 -> bf09c506", "bf033714 -> bf09c506",
"3fa3a04a -> 3fc90fdb", "3fa3a04a -> 3fc90fdb",
"3f1281b8 -> 3f1bfaf2", "3f1281b8 -> 3f1bfaf3",
"bf34447a -> bf480038", "bf34447a -> bf480038",
"bf9eb144 -> bfc90fdb", "bf9eb144 -> bfc90fdb",
"3f38d780 -> 3f4e86c5", "3f38d780 -> 3f4e86c5",
@ -25014,7 +25015,7 @@
"3f039538 -> 3f0a32b8", "3f039538 -> 3f0a32b8",
"3f4cd500 -> 3f6d70e3", "3f4cd500 -> 3f6d70e3",
"3ee655e4 -> 3eeee961", "3ee655e4 -> 3eeee961",
"3ef23d20 -> 3efc5474", "3ef23d20 -> 3efc5473",
"3e0c9370 -> 3e0d0572", "3e0c9370 -> 3e0d0572",
"3f9f079c -> 3fc90fdb", "3f9f079c -> 3fc90fdb",
"3e4b1770 -> 3e4c7278", "3e4b1770 -> 3e4c7278",
@ -25038,7 +25039,7 @@
"3f982f94 -> 3fc90fdb", "3f982f94 -> 3fc90fdb",
"3ef183b4 -> 3efb8213", "3ef183b4 -> 3efb8213",
"bed4d500 -> bedb7e63", "bed4d500 -> bedb7e63",
"bf49041d -> bf672e44", "bf49041d -> bf672e45",
"bf986f2a -> bfc90fdb", "bf986f2a -> bfc90fdb",
"3eeeeb04 -> 3ef89167", "3eeeeb04 -> 3ef89167",
"bf05421c -> bf0c27dd", "bf05421c -> bf0c27dd",
@ -25072,7 +25073,7 @@
"3f48bf60 -> 3f66bf61", "3f48bf60 -> 3f66bf61",
"3f953182 -> 3fc90fdb", "3f953182 -> 3fc90fdb",
"3e21e6d0 -> 3e229577", "3e21e6d0 -> 3e229577",
"bf043606 -> bf0aee58", "bf043606 -> bf0aee57",
"3d94f740 -> 3d9518f5", "3d94f740 -> 3d9518f5",
"3c15e400 -> 3c15e489", "3c15e400 -> 3c15e489",
"bfb51e92 -> bfc90fdb", "bfb51e92 -> bfc90fdb",
@ -25103,7 +25104,7 @@
"3f4133b0 -> 3f5aedab", "3f4133b0 -> 3f5aedab",
"3f25a7cc -> 3f3429d9", "3f25a7cc -> 3f3429d9",
"3f4f5e44 -> 3f71b78b", "3f4f5e44 -> 3f71b78b",
"3f795244 -> 3fabc288", "3f795244 -> 3fabc287",
"3de214a0 -> 3de28ad3", "3de214a0 -> 3de28ad3",
"3e8fe798 -> 3e91deba", "3e8fe798 -> 3e91deba",
"bf9af793 -> bfc90fdb", "bf9af793 -> bfc90fdb",
@ -25127,7 +25128,7 @@
"3ecad4fc -> 3ed08d21", "3ecad4fc -> 3ed08d21",
"3e05a058 -> 3e06022f", "3e05a058 -> 3e06022f",
"beb65270 -> beba69cf", "beb65270 -> beba69cf",
"3f5058c0 -> 3f7364a8", "3f5058c0 -> 3f7364a7",
"3fa6360c -> 3fc90fdb", "3fa6360c -> 3fc90fdb",
"3f466ed4 -> 3f630d19", "3f466ed4 -> 3f630d19",
"3f700570 -> 3f9b993a", "3f700570 -> 3f9b993a",
@ -25144,12 +25145,12 @@
"beaaf660 -> beae4f6a", "beaaf660 -> beae4f6a",
"3f92651c -> 3fc90fdb", "3f92651c -> 3fc90fdb",
"3f8dddfa -> 3fc90fdb", "3f8dddfa -> 3fc90fdb",
"3f2ed97c -> 3f407a92", "3f2ed97c -> 3f407a93",
"be5e0a78 -> be5fd19b", "be5e0a78 -> be5fd19b",
"bf794a5d -> bfabb123", "bf794a5d -> bfabb123",
"bf166554 -> bf20c10e", "bf166554 -> bf20c10e",
"3ec7db68 -> 3ecd50d0", "3ec7db68 -> 3ecd50d0",
"3f0f3a38 -> 3f1800b6", "3f0f3a38 -> 3f1800b5",
"bf9d2433 -> bfc90fdb", "bf9d2433 -> bfc90fdb",
"be1b6e70 -> be1c08d6", "be1b6e70 -> be1c08d6",
"3f8cf9f4 -> 3fc90fdb", "3f8cf9f4 -> 3fc90fdb",
@ -25170,7 +25171,7 @@
{ {
"glsl": "float cultmath_cycles_arccosine(float)", "glsl": "float cultmath_cycles_arccosine(float)",
"tolerance": "ulp-bounded, platform acos", "tolerance": "ulp-bounded, platform acos",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"3da419e0 -> 3fbecb6b", "3da419e0 -> 3fbecb6b",
"bccc7dc0 -> 3fcc41e7", "bccc7dc0 -> 3fcc41e7",
@ -25185,7 +25186,7 @@
"3f99f728 -> 00000000", "3f99f728 -> 00000000",
"3efe6a30 -> 3f867f99", "3efe6a30 -> 3f867f99",
"bf8e3141 -> 40490fdb", "bf8e3141 -> 40490fdb",
"be1ccb60 -> 3fdcbd18", "be1ccb60 -> 3fdcbd17",
"bf6312e3 -> 402a57f6", "bf6312e3 -> 402a57f6",
"beca9b18 -> 3ffd2360", "beca9b18 -> 3ffd2360",
"bfa261f2 -> 40490fdb", "bfa261f2 -> 40490fdb",
@ -25221,7 +25222,7 @@
"3e8561b8 -> 3fa753cc", "3e8561b8 -> 3fa753cc",
"bf888ac6 -> 40490fdb", "bf888ac6 -> 40490fdb",
"3fbb7d54 -> 00000000", "3fbb7d54 -> 00000000",
"3ec05018 -> 3f97c656", "3ec05018 -> 3f97c657",
"bf8c0f8c -> 40490fdb", "bf8c0f8c -> 40490fdb",
"bf72b2f0 -> 40345736", "bf72b2f0 -> 40345736",
"3f244db8 -> 3f5fba77", "3f244db8 -> 3f5fba77",
@ -25250,7 +25251,7 @@
"3f8f186c -> 00000000", "3f8f186c -> 00000000",
"3f30b1e8 -> 3f4f1b53", "3f30b1e8 -> 3f4f1b53",
"3d953310 -> 3fbfba8c", "3d953310 -> 3fbfba8c",
"bde0dee0 -> 3fd7250e", "bde0dee0 -> 3fd7250d",
"3f7b8300 -> 3e4007f6", "3f7b8300 -> 3e4007f6",
"3f35601c -> 3f488ecf", "3f35601c -> 3f488ecf",
"bf9a050a -> 40490fdb", "bf9a050a -> 40490fdb",
@ -25272,7 +25273,7 @@
"bfb4c70e -> 40490fdb", "bfb4c70e -> 40490fdb",
"3e93057c -> 3fa3c820", "3e93057c -> 3fa3c820",
"3f2b8624 -> 3f56293a", "3f2b8624 -> 3f56293a",
"bf51e010 -> 40220aae", "bf51e010 -> 40220aaf",
"bf8aa06c -> 40490fdb", "bf8aa06c -> 40490fdb",
"3f9839c8 -> 00000000", "3f9839c8 -> 00000000",
"3f28eed8 -> 3f59a156", "3f28eed8 -> 3f59a156",
@ -25297,7 +25298,7 @@
"3e8aea00 -> 3fa5e480", "3e8aea00 -> 3fa5e480",
"3ed35888 -> 3f9298c3", "3ed35888 -> 3f9298c3",
"3f567510 -> 3f13e3ce", "3f567510 -> 3f13e3ce",
"3e9761c8 -> 3fa2a464", "3e9761c8 -> 3fa2a463",
"3fbc3ca0 -> 00000000", "3fbc3ca0 -> 00000000",
"bf5814bd -> 4024d6ee", "bf5814bd -> 4024d6ee",
"3f807f68 -> 00000000", "3f807f68 -> 00000000",
@ -25306,7 +25307,7 @@
"3f1a4008 -> 3f6c92ef", "3f1a4008 -> 3f6c92ef",
"3fa491d4 -> 00000000", "3fa491d4 -> 00000000",
"3f385e00 -> 3f44484f", "3f385e00 -> 3f44484f",
"3e9ac620 -> 3fa1c0e6", "3e9ac620 -> 3fa1c0e5",
"3e9541ec -> 3fa3329f", "3e9541ec -> 3fa3329f",
"bf647da0 -> 402b1eac", "bf647da0 -> 402b1eac",
"3dc1c080 -> 3fbcef2e", "3dc1c080 -> 3fbcef2e",
@ -25351,7 +25352,7 @@
"beab7798 -> 3ff4c5fc", "beab7798 -> 3ff4c5fc",
"3f0df694 -> 3f7ba4b1", "3f0df694 -> 3f7ba4b1",
"3f94dda6 -> 00000000", "3f94dda6 -> 00000000",
"bf5c7d84 -> 4026f34c", "bf5c7d84 -> 4026f34d",
"3f9296ba -> 00000000", "3f9296ba -> 00000000",
"bfbada1e -> 40490fdb", "bfbada1e -> 40490fdb",
"3f8cf40a -> 00000000", "3f8cf40a -> 00000000",
@ -25364,7 +25365,7 @@
"bf6b7605 -> 402f4022", "bf6b7605 -> 402f4022",
"bfad549a -> 40490fdb", "bfad549a -> 40490fdb",
"3faa5d8c -> 00000000", "3faa5d8c -> 00000000",
"3e8b7c88 -> 3fa5be6e", "3e8b7c88 -> 3fa5be6f",
"3f3fa580 -> 3f398dc6", "3f3fa580 -> 3f398dc6",
"bf61690a -> 4029746e", "bf61690a -> 4029746e",
"bf73a264 -> 403516b7", "bf73a264 -> 403516b7",
@ -25427,13 +25428,13 @@
"3f930316 -> 00000000", "3f930316 -> 00000000",
"bf6bfdd5 -> 402f9730", "bf6bfdd5 -> 402f9730",
"3fa53312 -> 00000000", "3fa53312 -> 00000000",
"bf1a0e9a -> 400ddba4" "bf1a0e9a -> 400ddba5"
] ]
}, },
{ {
"glsl": "float cultmath_cycles_arctan2(float, float)", "glsl": "float cultmath_cycles_arctan2(float, float)",
"tolerance": "ulp-bounded, platform atan", "tolerance": "ulp-bounded, platform atan",
"platform": "Ubuntu 24.04.5 LTS; .NET 10.0.12", "platform": "Debian GNU/Linux 13 (trixie); .NET 10.0.14",
"cases": [ "cases": [
"c08cbc16 c1549681 -> c0349aac", "c08cbc16 c1549681 -> c0349aac",
"418c8108 41948858 -> 3f41f3ee", "418c8108 41948858 -> 3f41f3ee",
@ -25442,7 +25443,7 @@
"c1853a3e 4183660c -> bf4ad4c8", "c1853a3e 4183660c -> bf4ad4c8",
"40cc3750 c18864ad -> 4032239a", "40cc3750 c18864ad -> 4032239a",
"c1841fce 414eb7b4 -> bf682df1", "c1841fce 414eb7b4 -> bf682df1",
"3f7f11c0 41915dcc -> 3d605f59", "3f7f11c0 41915dcc -> 3d605f58",
"c1190690 40822148 -> bf959a14", "c1190690 40822148 -> bf959a14",
"41235546 414a8e7c -> 3f2db91b", "41235546 414a8e7c -> 3f2db91b",
"c173027e 4157ae48 -> bf584be7", "c173027e 4157ae48 -> bf584be7",
@ -25451,12 +25452,12 @@
"41504298 c1432d3c -> 4014b8bf", "41504298 c1432d3c -> 4014b8bf",
"41976732 4165afb0 -> 3f6bff17", "41976732 4165afb0 -> 3f6bff17",
"c12faa83 c19c5692 -> c0284d67", "c12faa83 c19c5692 -> c0284d67",
"c0aa038c c0cb2194 -> c01c7634", "c0aa038c c0cb2194 -> c01c7633",
"c0df2a78 c0a67120 -> c00d8b25", "c0df2a78 c0a67120 -> c00d8b25",
"4144767c c11a39a5 -> 400f2006", "4144767c c11a39a5 -> 400f2006",
"c00c8aa0 4194be56 -> bdf0c4af", "c00c8aa0 4194be56 -> bdf0c4af",
"3f876310 c1937378 -> 404564aa", "3f876310 c1937378 -> 404564aa",
"41636814 c1172a84 -> 400a13e2", "41636814 c1172a84 -> 400a13e1",
"3f81e2e0 bd4f6800 -> 3fcf71a7", "3f81e2e0 bd4f6800 -> 3fcf71a7",
"41309010 c08f8f1c -> 3ffa7c90", "41309010 c08f8f1c -> 3ffa7c90",
"40982a9c 3f904760 -> 3fab4541", "40982a9c 3f904760 -> 3fab4541",
@ -25467,23 +25468,23 @@
"4192314a 4086f2c0 -> 3fac080a", "4192314a 4086f2c0 -> 3fac080a",
"411b37ca c171da02 -> 40248b70", "411b37ca c171da02 -> 40248b70",
"c1664f18 c130242c -> c00e512f", "c1664f18 c130242c -> c00e512f",
"40eced38 40bd9198 -> 3f655f42", "40eced38 40bd9198 -> 3f655f43",
"4104386c 411d5130 -> 3f32ed70", "4104386c 411d5130 -> 3f32ed71",
"bdd98800 c15cd654 -> c04891c6", "bdd98800 c15cd654 -> c04891c6",
"c161bae2 c19f6675 -> c021a16c", "c161bae2 c19f6675 -> c021a16c",
"c15bccb4 4139d89a -> bf5e709e", "c15bccb4 4139d89a -> bf5e709e",
"c1065de6 c1860dc7 -> c02b5428", "c1065de6 c1860dc7 -> c02b5428",
"419a2318 416c82f8 -> 3f6a973f", "419a2318 416c82f8 -> 3f6a973f",
"bf911740 3eafb040 -> bfa36fa4", "bf911740 3eafb040 -> bfa36fa5",
"41961b7c c01fd768 -> 3fd9fffb", "41961b7c c01fd768 -> 3fd9fffb",
"4042f5d8 c00cea20 -> 400c95f1", "4042f5d8 c00cea20 -> 400c95f1",
"c12f053d 418e307c -> bf0d3c4a", "c12f053d 418e307c -> bf0d3c4b",
"403d4400 c11dc866 -> 40366a62", "403d4400 c11dc866 -> 40366a62",
"c15831c7 412e7716 -> bf644dcc", "c15831c7 412e7716 -> bf644dcc",
"c0d29330 41698de0 -> bed8d810", "c0d29330 41698de0 -> bed8d810",
"c0fd3872 415963f8 -> bf0702af", "c0fd3872 415963f8 -> bf0702af",
"c131bb1f c074e670 -> bff386a5", "c131bb1f c074e670 -> bff386a5",
"417f6828 40a71880 -> 3fa09868", "417f6828 40a71880 -> 3fa09869",
"40497b80 41717390 -> 3e529a28", "40497b80 41717390 -> 3e529a28",
"413af82e c196608f -> 40257713", "413af82e c196608f -> 40257713",
"4179eb80 bfb8a2f0 -> 3fd4d959", "4179eb80 bfb8a2f0 -> 3fd4d959",
@ -25492,8 +25493,8 @@
"414f2488 c180a1ba -> 401dad4f", "414f2488 c180a1ba -> 401dad4f",
"4036b5f0 c15671f1 -> 403ba162", "4036b5f0 c15671f1 -> 403ba162",
"bf97a3d0 c0046bb8 -> c027c818", "bf97a3d0 c0046bb8 -> c027c818",
"40ae84d8 c0f9a4b6 -> 402203ec", "40ae84d8 c0f9a4b6 -> 402203eb",
"3feb4440 40f9d6e0 -> 3e6cc1c5", "3feb4440 40f9d6e0 -> 3e6cc1c6",
"419f7088 411a15a2 -> 3f8f723b", "419f7088 411a15a2 -> 3f8f723b",
"c188441a bfeff970 -> bfd71812", "c188441a bfeff970 -> bfd71812",
"40560bd0 410fd5de -> 3eb65b86", "40560bd0 410fd5de -> 3eb65b86",
@ -25508,10 +25509,10 @@
"414c9fc4 418893dc -> 3f249745", "414c9fc4 418893dc -> 3f249745",
"c033b3b8 c18b7729 -> c03ed777", "c033b3b8 c18b7729 -> c03ed777",
"c13f3667 c152d7a7 -> c019eb30", "c13f3667 c152d7a7 -> c019eb30",
"c086fb50 41627eb4 -> be9446ba", "c086fb50 41627eb4 -> be9446b9",
"c13dde75 41858b9e -> bf1e348f", "c13dde75 41858b9e -> bf1e348f",
"41655ddc c13d8347 -> 4010b9bb", "41655ddc c13d8347 -> 4010b9bb",
"40a95660 41584940 -> 3ebf0aa4", "40a95660 41584940 -> 3ebf0aa3",
"4141fbc4 c0ce1146 -> 4003c77a", "4141fbc4 c0ce1146 -> 4003c77a",
"c0091050 c155ea5c -> c03ee580", "c0091050 c155ea5c -> c03ee580",
"40b8f0c0 c18079b9 -> 4032f416", "40b8f0c0 c18079b9 -> 4032f416",
@ -25524,17 +25525,17 @@
"40a7d388 c07a5f68 -> 400d8c45", "40a7d388 c07a5f68 -> 400d8c45",
"4186baee c161afee -> 4011273b", "4186baee c161afee -> 4011273b",
"c1988920 3f471480 -> bfc3d822", "c1988920 3f471480 -> bfc3d822",
"3f70c440 40be95d8 -> 3e2060cf", "3f70c440 40be95d8 -> 3e2060d0",
"417834e8 415d5098 -> 3f57b555", "417834e8 415d5098 -> 3f57b555",
"c1775234 40c0f8f8 -> bf99734e", "c1775234 40c0f8f8 -> bf99734e",
"c1974114 4198ffc8 -> bf479800", "c1974114 4198ffc8 -> bf479800",
"410c0ec4 bff12930 -> 3fe432a6", "410c0ec4 bff12930 -> 3fe432a6",
"413a6dc4 c09ce414 -> 3ffc0b0b", "413a6dc4 c09ce414 -> 3ffc0b0b",
"be987ac0 c097edfc -> c0450d7e", "be987ac0 c097edfc -> c0450d7e",
"bfa53460 4194fa8e -> bd8db6e0", "bfa53460 4194fa8e -> bd8db6e1",
"40d38cc8 c19c290d -> 403429aa", "40d38cc8 c19c290d -> 403429aa",
"415e9230 bea6b840 -> 3fcc0ec1", "415e9230 bea6b840 -> 3fcc0ec1",
"c1612e94 4199eb08 -> bf21ad98", "c1612e94 4199eb08 -> bf21ad99",
"c102419c 3f3e0580 -> bfbd6c6a", "c102419c 3f3e0580 -> bfbd6c6a",
"c171e10c 3e75cd00 -> bfc70799", "c171e10c 3e75cd00 -> bfc70799",
"c159f7c3 c040e368 -> bfe4ee5c", "c159f7c3 c040e368 -> bfe4ee5c",
@ -25543,18 +25544,18 @@
"c0ebecc4 4188a552 -> bed0a0ee", "c0ebecc4 4188a552 -> bed0a0ee",
"40b612c0 419e96ba -> 3e8f1b96", "40b612c0 419e96ba -> 3e8f1b96",
"c1392859 c184cf09 -> c02219d5", "c1392859 c184cf09 -> c02219d5",
"40f88828 c0379bc8 -> 3ff65a7b", "40f88828 c0379bc8 -> 3ff65a7c",
"4183657e 418c205a -> 3f40d57c", "4183657e 418c205a -> 3f40d57c",
"3d64c400 c049c398 -> 4047eda0", "3d64c400 c049c398 -> 4047eda0",
"c184e5e2 c0ba5c6c -> bff438b2", "c184e5e2 c0ba5c6c -> bff438b3",
"c13cf1d2 c09df406 -> bffbbd3f", "c13cf1d2 c09df406 -> bffbbd40",
"41951ea0 40d3e958 -> 3f9d5e14", "41951ea0 40d3e958 -> 3f9d5e14",
"4117a1c4 4182e3fa -> 3f0666f9", "4117a1c4 4182e3fa -> 3f0666f9",
"c100421b 3fa11c00 -> bfb52067", "c100421b 3fa11c00 -> bfb52067",
"c0a4c048 3fd9b1a0 -> bfa0392a", "c0a4c048 3fd9b1a0 -> bfa0392a",
"c0bce6f0 c0a756e0 -> c012ed67", "c0bce6f0 c0a756e0 -> c012ed67",
"410f31a8 4089ad2c -> 3f8fb3fa", "410f31a8 4089ad2c -> 3f8fb3f9",
"beb17800 40836d64 -> bdac6ea3", "beb17800 40836d64 -> bdac6ea4",
"c0724d88 bee688c0 -> bfd836b1", "c0724d88 bee688c0 -> bfd836b1",
"3fb83bc0 c1693dec -> 4042c367", "3fb83bc0 c1693dec -> 4042c367",
"c18c3dd4 4027c610 -> bfb60fc6", "c18c3dd4 4027c610 -> bfb60fc6",
@ -25562,7 +25563,7 @@
"41231d74 404d91d0 -> 3fa1fe4e", "41231d74 404d91d0 -> 3fa1fe4e",
"c0e92c5a c04e1b80 -> bffe543d", "c0e92c5a c04e1b80 -> bffe543d",
"404ffd08 c1396f46 -> 403790b4", "404ffd08 c1396f46 -> 403790b4",
"bf50eb60 41702f48 -> bd5e7502", "bf50eb60 41702f48 -> bd5e7501",
"c0294f90 412b4d2a -> be780e6d", "c0294f90 412b4d2a -> be780e6d",
"c0fe6df0 41350240 -> bf1cd3f4", "c0fe6df0 41350240 -> bf1cd3f4",
"bfd3b0d0 41824f24 -> bdcf3ab0", "bfd3b0d0 41824f24 -> bdcf3ab0",
@ -25571,15 +25572,15 @@
"400eab20 c180166a -> 4040354a", "400eab20 c180166a -> 4040354a",
"40b741d8 415a8318 -> 3ecb4b31", "40b741d8 415a8318 -> 3ecb4b31",
"c0b19f72 c1844941 -> c03455ba", "c0b19f72 c1844941 -> c03455ba",
"418bd268 c0f5d76c -> 3ffe1262", "418bd268 c0f5d76c -> 3ffe1263",
"3fa6a0a0 c19bf37a -> 4044cb60", "3fa6a0a0 c19bf37a -> 4044cb60",
"417687a4 418fb914 -> 3f357bee", "417687a4 418fb914 -> 3f357bee",
"c104b28d 40b23950 -> bf7abbaa", "c104b28d 40b23950 -> bf7abba9",
"40b00cb0 be7bb000 -> 3fcec6d5", "40b00cb0 be7bb000 -> 3fcec6d5",
"41836b9a c15bc2b2 -> 40111937", "41836b9a c15bc2b2 -> 40111937",
"40697890 417344c4 -> 3e7121a8", "40697890 417344c4 -> 3e7121a8",
"4190121e c158f0df -> 400dd562", "4190121e c158f0df -> 400dd562",
"c18bdd8b bf6a2c00 -> bfcfc0bd", "c18bdd8b bf6a2c00 -> bfcfc0be",
"418b5438 4191f240 -> 3f431ff0", "418b5438 4191f240 -> 3f431ff0",
"c0f7e4e4 410571fe -> bf3f9e8f", "c0f7e4e4 410571fe -> bf3f9e8f",
"c163d334 bd98d600 -> bfc9bb97", "c163d334 bd98d600 -> bfc9bb97",
@ -25590,27 +25591,27 @@
"c133cbd1 c12c03ab -> c015618c", "c133cbd1 c12c03ab -> c015618c",
"bf32bb00 c1111506 -> c04424d8", "bf32bb00 c1111506 -> c04424d8",
"40e3794c bfa93e10 -> 3fe099e8", "40e3794c bfa93e10 -> 3fe099e8",
"419cd3d2 bf1c60c0 -> 3fcd0c96", "419cd3d2 bf1c60c0 -> 3fcd0c97",
"bf800240 40cf58c0 -> be1ccef2", "bf800240 40cf58c0 -> be1ccef2",
"c1630418 4194bc52 -> bf26e0ae", "c1630418 4194bc52 -> bf26e0ae",
"419ee38a c0d13810 -> 3ff1c4a8", "419ee38a c0d13810 -> 3ff1c4a9",
"c0554ad8 bf756f80 -> bfecea8c", "c0554ad8 bf756f80 -> bfecea8c",
"4155c1e0 c086c8b0 -> 3ff02782", "4155c1e0 c086c8b0 -> 3ff02782",
"c19438df c155bc54 -> c00c82ae", "c19438df c155bc54 -> c00c82ae",
"4181ed52 c072d0e0 -> 3fe66fdc", "4181ed52 c072d0e0 -> 3fe66fdc",
"41335fee c12dcd2f -> 4015c95e", "41335fee c12dcd2f -> 4015c95e",
"c1571882 c194fd20 -> c0210be4", "c1571882 c194fd20 -> c0210be4",
"c19a88b4 c0b6e8d0 -> bfede30f", "c19a88b4 c0b6e8d0 -> bfede310",
"c153621e c19b2864 -> c022ca69", "c153621e c19b2864 -> c022ca68",
"40a5abdc 419f6cf2 -> 3e822341", "40a5abdc 419f6cf2 -> 3e822342",
"40d30c58 419f1e6c -> 3ea3ee9c", "40d30c58 419f1e6c -> 3ea3ee9c",
"418c0490 c0b33c62 -> 3ff0b4e5", "418c0490 c0b33c62 -> 3ff0b4e5",
"c0831f14 40e27288 -> bf065d6a", "c0831f14 40e27288 -> bf065d69",
"bf2ad860 4075b2b0 -> be303f57", "bf2ad860 4075b2b0 -> be303f56",
"bf04a640 bffc8c00 -> c038a00e", "bf04a640 bffc8c00 -> c038a00e",
"bf41b3a0 40b8c260 -> be056f50", "bf41b3a0 40b8c260 -> be056f50",
"4021d860 c14d4bd9 -> 403c9ba3", "4021d860 c14d4bd9 -> 403c9ba3",
"3d83a000 40cb8cf8 -> 3c258915", "3d83a000 40cb8cf8 -> 3c258914",
"c178b13e c0fba980 -> c0028267", "c178b13e c0fba980 -> c0028267",
"c0108e98 40935624 -> bee9826e", "c0108e98 40935624 -> bee9826e",
"40833c68 40d08ed0 -> 3f0fc87b", "40833c68 40d08ed0 -> 3f0fc87b",
@ -25623,13 +25624,13 @@
"c1000d87 419fc51c -> bec32579", "c1000d87 419fc51c -> bec32579",
"c0814a50 c18dda43 -> c03ab943", "c0814a50 c18dda43 -> c03ab943",
"c186e3b7 c15b7765 -> c0103ce8", "c186e3b7 c15b7765 -> c0103ce8",
"4183a496 c01779a8 -> 3fdb58d2", "4183a496 c01779a8 -> 3fdb58d3",
"40e51c20 bfe49ed0 -> 3fe85ad7", "40e51c20 bfe49ed0 -> 3fe85ad7",
"4187b382 c1690142 -> 4011ef14", "4187b382 c1690142 -> 4011ef13",
"40d37970 419b19ac -> 3ea83333", "40d37970 419b19ac -> 3ea83332",
"3fa7f3f0 c0b5afc8 -> 403a86cd", "3fa7f3f0 c0b5afc8 -> 403a86cd",
"c122c32b 416a72a0 -> bf1b5a62", "c122c32b 416a72a0 -> bf1b5a62",
"40374b70 c19b6a93 -> 403fb1c2", "40374b70 c19b6a93 -> 403fb1c3",
"c08588a0 c19f609e -> c03bd8f2", "c08588a0 c19f609e -> c03bd8f2",
"419cbe90 c170df46 -> 400e75e0", "419cbe90 c170df46 -> 400e75e0",
"4182e2b4 c1643109 -> 40126ac4", "4182e2b4 c1643109 -> 40126ac4",
@ -25642,9 +25643,9 @@
"c1300d51 c108ac40 -> c00ec793", "c1300d51 c108ac40 -> c00ec793",
"410261e6 c1588362 -> 40265f3f", "410261e6 c1588362 -> 40265f3f",
"419adcac 40054e48 -> 3fbb5784", "419adcac 40054e48 -> 3fbb5784",
"41798160 c0ecc4b0 -> 4000e25e", "41798160 c0ecc4b0 -> 4000e25f",
"40639318 c1702fdd -> 403a2d2c", "40639318 c1702fdd -> 403a2d2c",
"c1794797 c1829f3b -> c0184bca", "c1794797 c1829f3b -> c0184bc9",
"c0904192 c0035a40 -> bfffbf67", "c0904192 c0035a40 -> bfffbf67",
"41787da0 c19a2a35 -> 401da5e0", "41787da0 c19a2a35 -> 401da5e0",
"4120e654 c1014c4b -> 400fdab9", "4120e654 c1014c4b -> 400fdab9",
@ -25664,15 +25665,15 @@
"4112e32a c18e9f42 -> 402aa0a2", "4112e32a c18e9f42 -> 402aa0a2",
"bfaa2d50 40ef51f8 -> be3427dc", "bfaa2d50 40ef51f8 -> be3427dc",
"c16ae95c 406f7cb0 -> bfa91e64", "c16ae95c 406f7cb0 -> bfa91e64",
"40d2db80 405037e8 -> 3f8e5b81", "40d2db80 405037e8 -> 3f8e5b82",
"be920280 419b18c2 -> bc70fbde", "be920280 419b18c2 -> bc70fbde",
"41823e9c c10676ec -> 4003074a", "41823e9c c10676ec -> 4003074a",
"40fc01e0 40a9689c -> 3f7a9c4a", "40fc01e0 40a9689c -> 3f7a9c4a",
"416ade10 c15a3b2c -> 40147298", "416ade10 c15a3b2c -> 40147298",
"c12b4626 419bd752 -> bf00a1ec", "c12b4626 419bd752 -> bf00a1ec",
"413a29aa c0cd07be -> 4004befe", "413a29aa c0cd07be -> 4004beff",
"c199d0c3 c1866487 -> c0127d6a", "c199d0c3 c1866487 -> c0127d6a",
"419e0288 c1425a56 -> 4007d1b2", "419e0288 c1425a56 -> 4007d1b3",
"4144a97c 4083fd0c -> 3f9f9ec3", "4144a97c 4083fd0c -> 3f9f9ec3",
"4104e2a2 3f327440 -> 3fbe57fd", "4104e2a2 3f327440 -> 3fbe57fd",
"c10cd1b9 40378e30 -> bfa0bd4e", "c10cd1b9 40378e30 -> bfa0bd4e",
@ -25685,7 +25686,7 @@
"3fc08c60 c177750c -> 4042db49", "3fc08c60 c177750c -> 4042db49",
"c10a43a9 c0be1246 -> c00b121d", "c10a43a9 c0be1246 -> c00b121d",
"4061f8c0 40e4294c -> 3eeb6b1a", "4061f8c0 40e4294c -> 3eeb6b1a",
"c0aa3df6 4165c098 -> beb5a9ce", "c0aa3df6 4165c098 -> beb5a9cf",
"c13c904c c1220f4d -> c011f7c3", "c13c904c c1220f4d -> c011f7c3",
"4141fe18 c13691ef -> 4014db0f", "4141fe18 c13691ef -> 4014db0f",
"c043e348 40bf5874 -> bef239e5", "c043e348 40bf5874 -> bef239e5",
@ -32770,6 +32771,268 @@
"c486ccb7 4464268f 42b60000 44088700 42475598 00000000 43258de7 44372289 44df86e2 00000000 43904267 42f00000 c2b40000 40e00000 42b80000 43200000 c2f00000 40e00000 42ba0000 43480000 c3160000 40e00000 42bc0000 43700000 c3340000 40e00000 42be0000 438c0000 c3520000 40e00000 42c00000 43a00000 c3700000 40e00000 42c20000 43b40000 c3870000 40e00000 42c40000 43c80000 c3960000 40e00000 42c60000 43dc0000 c3a50000 40e00000 42c80000 43f00000 c3b40000 40e00000 42ca0000 44020000 c3c30000 40e00000 42cc0000 440c0000 c3d20000 40e00000 42ce0000 44160000 c3e10000 40e00000 42d00000 44200000 c3f00000 40e00000 42d20000 442a0000 c3ff0000 40e00000 42d40000 44340000 c4070000 40e00000 42d60000 443e0000 c40e8000 40e00000 42d80000 44480000 c4160000 40e00000 42da0000 44520000 c41d8000 40e00000 42dc0000 445c0000 c4250000 40e00000 42de0000 44660000 c42c8000 40e00000 42e00000 44700000 c4340000 40e00000 42e20000 447a0000 c43b8000 40e00000 42e40000 44820000 c4430000 40e00000 42e60000 44870000 c44a8000 40e00000 42e80000 448c0000 c4520000 40e00000 42ea0000 44910000 c4598000 40e00000 42ec0000 44960000 c4610000 40e00000 42ee0000 449b0000 c4688000 40e00000 42f00000 44a00000 c4700000 40e00000 42f20000 44a50000 c4778000 40e00000 42f40000 44aa0000 c47f0000 40e00000 42f60000 44af0000 c4834000 40e00000 42f80000 44b40000 c4870000 40e00000 42fa0000 44b90000 c48ac000 40e00000 42fc0000 44be0000 c48e8000 40e00000 42fe0000 44c30000 c4924000 40e00000 43000000 44c80000 c4960000 40e00000 43010000 44cd0000 c499c000 40e00000 43020000 44d20000 c49d8000 40e00000 43030000 44d70000 c4a14000 40e00000 43040000 44dc0000 c4a50000 40e00000 43050000 44e10000 c4a8c000 40e00000 43060000 44e60000 c4ac8000 40e00000 43070000 44eb0000 c4b04000 40e00000 43080000 44f00000 c4b40000 40e00000 43090000 44f50000 c4b7c000 40e00000 430a0000 44fa0000 c4bb8000 40e00000 430b0000 44ff0000 c4bf4000 40e00000 430c0000 45020000 c4c30000 40e00000 430d0000 45048000 c4c6c000 40e00000 430e0000 45070000 c4ca8000 40e00000 430f0000 45098000 c4ce4000 40e00000 43100000 450c0000 c4d20000 40e00000 43110000 450e8000 c4d5c000 40e00000 43120000 45110000 c4d98000 40e00000 43130000 45138000 c4dd4000 40e00000 43140000 45160000 c4e10000 40e00000 43150000 45188000 c4e4c000 40e00000 43160000 451b0000 c4e88000 40e00000 43170000 451d8000 c4ec4000 40e00000 43180000 45200000 c4f00000 40e00000 43190000 45228000 c4f3c000 40e00000 431a0000 45250000 c4f78000 40e00000 431b0000 45278000 c4fb4000 40e00000 431c0000 452a0000 c4ff0000 40e00000 431d0000 452c8000 c5016000 40e00000 431e0000 452f0000 c5034000 40e00000 431f0000 45318000 c5052000 40e00000 43200000 45340000 c5070000 40e00000 43210000 45368000 c508e000 40e00000 43220000 45390000 c50ac000 40e00000 43230000 453b8000 c50ca000 40e00000 43240000 453e0000 c50e8000 40e00000 43250000 45408000 c5106000 40e00000 43260000 45430000 c5124000 40e00000 43270000 45458000 c5142000 40e00000 43280000 45480000 c5160000 40e00000 43290000 454a8000 c517e000 40e00000 432a0000 454d0000 c519c000 40e00000 432b0000 454f8000 c51ba000 40e00000 432c0000 45520000 c51d8000 40e00000 432d0000 45548000 c51f6000 40e00000 432e0000 45570000 c5214000 40e00000 432f0000 45598000 c5232000 40e00000 43300000 455c0000 c5250000 40e00000 43310000 455e8000 c526e000 40e00000 43320000 45610000 c528c000 40e00000 43330000 45638000 c52aa000 40e00000 43340000 45660000 c52c8000 40e00000 43350000 45688000 c52e6000 40e00000 43360000 456b0000 c5304000 40e00000 43370000 456d8000 c5322000 40e00000 43380000 45700000 c5340000 40e00000 43390000 45728000 c535e000 40e00000 433a0000 45750000 c537c000 40e00000 433b0000 45778000 c539a000 40e00000 433c0000 457a0000 c53b8000 40e00000 433d0000 457c8000 c53d6000 40e00000 433e0000 457f0000 c53f4000 40e00000 433f0000 4580c000 c5412000 40e00000 43400000 45820000 c5430000 40e00000 43410000 45834000 c544e000 40e00000 43420000 45848000 c546c000 40e00000 43430000 4585c000 c548a000 40e00000 43440000 45870000 c54a8000 40e00000 43450000 45884000 c54c6000 40e00000 43460000 45898000 c54e4000 40e00000 43470000 458ac000 c5502000 40e00000 43480000 458c0000 c5520000 40e00000 43490000 458d4000 c553e000 40e00000 434a0000 458e8000 c555c000 40e00000 434b0000 458fc000 c557a000 40e00000 434c0000 45910000 c5598000 40e00000 434d0000 45924000 c55b6000 40e00000 434e0000 45938000 c55d4000 40e00000 434f0000 4594c000 c55f2000 40e00000 43500000 45960000 c5610000 40e00000 43510000 45974000 c562e000 40e00000 43520000 45988000 c564c000 40e00000 43530000 4599c000 c566a000 40e00000 43540000 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 00000002 43820000 3f333333 3fcccccd 42f00000 3eb33333 00000000 00000000 3f800000 -> 3ef8c434 3f32636d 3efbcd58 3e549b04", "c486ccb7 4464268f 42b60000 44088700 42475598 00000000 43258de7 44372289 44df86e2 00000000 43904267 42f00000 c2b40000 40e00000 42b80000 43200000 c2f00000 40e00000 42ba0000 43480000 c3160000 40e00000 42bc0000 43700000 c3340000 40e00000 42be0000 438c0000 c3520000 40e00000 42c00000 43a00000 c3700000 40e00000 42c20000 43b40000 c3870000 40e00000 42c40000 43c80000 c3960000 40e00000 42c60000 43dc0000 c3a50000 40e00000 42c80000 43f00000 c3b40000 40e00000 42ca0000 44020000 c3c30000 40e00000 42cc0000 440c0000 c3d20000 40e00000 42ce0000 44160000 c3e10000 40e00000 42d00000 44200000 c3f00000 40e00000 42d20000 442a0000 c3ff0000 40e00000 42d40000 44340000 c4070000 40e00000 42d60000 443e0000 c40e8000 40e00000 42d80000 44480000 c4160000 40e00000 42da0000 44520000 c41d8000 40e00000 42dc0000 445c0000 c4250000 40e00000 42de0000 44660000 c42c8000 40e00000 42e00000 44700000 c4340000 40e00000 42e20000 447a0000 c43b8000 40e00000 42e40000 44820000 c4430000 40e00000 42e60000 44870000 c44a8000 40e00000 42e80000 448c0000 c4520000 40e00000 42ea0000 44910000 c4598000 40e00000 42ec0000 44960000 c4610000 40e00000 42ee0000 449b0000 c4688000 40e00000 42f00000 44a00000 c4700000 40e00000 42f20000 44a50000 c4778000 40e00000 42f40000 44aa0000 c47f0000 40e00000 42f60000 44af0000 c4834000 40e00000 42f80000 44b40000 c4870000 40e00000 42fa0000 44b90000 c48ac000 40e00000 42fc0000 44be0000 c48e8000 40e00000 42fe0000 44c30000 c4924000 40e00000 43000000 44c80000 c4960000 40e00000 43010000 44cd0000 c499c000 40e00000 43020000 44d20000 c49d8000 40e00000 43030000 44d70000 c4a14000 40e00000 43040000 44dc0000 c4a50000 40e00000 43050000 44e10000 c4a8c000 40e00000 43060000 44e60000 c4ac8000 40e00000 43070000 44eb0000 c4b04000 40e00000 43080000 44f00000 c4b40000 40e00000 43090000 44f50000 c4b7c000 40e00000 430a0000 44fa0000 c4bb8000 40e00000 430b0000 44ff0000 c4bf4000 40e00000 430c0000 45020000 c4c30000 40e00000 430d0000 45048000 c4c6c000 40e00000 430e0000 45070000 c4ca8000 40e00000 430f0000 45098000 c4ce4000 40e00000 43100000 450c0000 c4d20000 40e00000 43110000 450e8000 c4d5c000 40e00000 43120000 45110000 c4d98000 40e00000 43130000 45138000 c4dd4000 40e00000 43140000 45160000 c4e10000 40e00000 43150000 45188000 c4e4c000 40e00000 43160000 451b0000 c4e88000 40e00000 43170000 451d8000 c4ec4000 40e00000 43180000 45200000 c4f00000 40e00000 43190000 45228000 c4f3c000 40e00000 431a0000 45250000 c4f78000 40e00000 431b0000 45278000 c4fb4000 40e00000 431c0000 452a0000 c4ff0000 40e00000 431d0000 452c8000 c5016000 40e00000 431e0000 452f0000 c5034000 40e00000 431f0000 45318000 c5052000 40e00000 43200000 45340000 c5070000 40e00000 43210000 45368000 c508e000 40e00000 43220000 45390000 c50ac000 40e00000 43230000 453b8000 c50ca000 40e00000 43240000 453e0000 c50e8000 40e00000 43250000 45408000 c5106000 40e00000 43260000 45430000 c5124000 40e00000 43270000 45458000 c5142000 40e00000 43280000 45480000 c5160000 40e00000 43290000 454a8000 c517e000 40e00000 432a0000 454d0000 c519c000 40e00000 432b0000 454f8000 c51ba000 40e00000 432c0000 45520000 c51d8000 40e00000 432d0000 45548000 c51f6000 40e00000 432e0000 45570000 c5214000 40e00000 432f0000 45598000 c5232000 40e00000 43300000 455c0000 c5250000 40e00000 43310000 455e8000 c526e000 40e00000 43320000 45610000 c528c000 40e00000 43330000 45638000 c52aa000 40e00000 43340000 45660000 c52c8000 40e00000 43350000 45688000 c52e6000 40e00000 43360000 456b0000 c5304000 40e00000 43370000 456d8000 c5322000 40e00000 43380000 45700000 c5340000 40e00000 43390000 45728000 c535e000 40e00000 433a0000 45750000 c537c000 40e00000 433b0000 45778000 c539a000 40e00000 433c0000 457a0000 c53b8000 40e00000 433d0000 457c8000 c53d6000 40e00000 433e0000 457f0000 c53f4000 40e00000 433f0000 4580c000 c5412000 40e00000 43400000 45820000 c5430000 40e00000 43410000 45834000 c544e000 40e00000 43420000 45848000 c546c000 40e00000 43430000 4585c000 c548a000 40e00000 43440000 45870000 c54a8000 40e00000 43450000 45884000 c54c6000 40e00000 43460000 45898000 c54e4000 40e00000 43470000 458ac000 c5502000 40e00000 43480000 458c0000 c5520000 40e00000 43490000 458d4000 c553e000 40e00000 434a0000 458e8000 c555c000 40e00000 434b0000 458fc000 c557a000 40e00000 434c0000 45910000 c5598000 40e00000 434d0000 45924000 c55b6000 40e00000 434e0000 45938000 c55d4000 40e00000 434f0000 4594c000 c55f2000 40e00000 43500000 45960000 c5610000 40e00000 43510000 45974000 c562e000 40e00000 43520000 45988000 c564c000 40e00000 43530000 4599c000 c566a000 40e00000 43540000 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 00000002 43820000 3f333333 3fcccccd 42f00000 3eb33333 00000000 00000000 3f800000 -> 3ef8c434 3f32636d 3efbcd58 3e549b04",
"c4b3e76c 444241a0 42500000 435b9579 4136dbcf 00000000 43945431 43041651 43830dcc 00000000 43652355 c48bf352 44d33444 00000000 434dc873 c3000002 c10833d3 00000000 433550ff c28b6e94 c30f7278 00000000 438d43a6 44a4de09 c4f12ff1 00000000 425a0d22 44d428d3 430976c5 00000000 434b973e 441d5713 44a7fc0b 00000000 438e7c40 c4ab19d7 44f2b987 00000000 41e038cc c455d2d5 c2a36170 00000000 43908b8d c3801d60 c40e138a 00000000 431363cc 43396792 c37f2156 00000000 437ac80c 439f85f6 420c7863 00000000 42150b58 4428e36e 44c2bb96 00000000 43837958 c47c7670 44a88b01 00000000 4294d48d c40641aa c285e8ba 00000000 4232d05c c40ca53f c4a8ca3a 00000000 4378763b 42cfefd3 c306ba40 00000000 430371e3 44b979fd 434ebfb9 00000000 43421a7a 42b44286 43616d19 00000000 43047467 c2892c2a 42ac8e8b 00000000 4332087f c4f373b1 c39604df 00000000 42b3908c c4071e02 c4b04c5b 00000000 4347459e 43fd006f c41a827e 00000000 4208fda6 44c75602 43869ead 00000000 42421b44 4351a83b 440ee820 00000000 42cc9909 c48345c8 449bb4d1 00000000 42125362 c4abe6d8 c37c9d2e 00000000 433a98a6 c43a8ecd c5050f9b 00000000 42b796d8 4488f948 c49dcc59 00000000 43638b9c 4518e99d 43f2fa58 00000000 4271681e 4436363e 450837cb 00000000 437c8686 c49f0216 44b1f01c 00000000 438026fe c4af2be6 c395b304 00000000 43814487 c3f349af c4befb18 00000000 428c07fa 4424afd0 c4330845 00000000 42decd7b 4513cf17 44073fe5 00000000 438a0efc 442d8bbd 450f5844 00000000 434b07a5 c493ef20 449c3dfb 00000000 437f587e c4222d07 c31e43fc 00000000 43128220 c4066f1d c4ea31b9 00000000 4340306c 43e81d81 c3ee2e59 00000000 4330577c 4514ae2d 441a3491 00000000 43869616 439ae709 448ea55a 00000000 43226a3e c4068e80 440626bb 00000000 430d975e c49c9b56 c3ac1805 00000000 42dea520 c4161f98 c5129034 00000000 42da3948 44400c36 c43a07ca 00000000 43826b94 44c6d8dc 43e6e324 00000000 438edae5 4396e0d8 449ca83c 00000000 43643dd5 c35d19fc 4350154a 00000000 430efc77 c485b6e4 c3a3a64d 00000000 4312ff68 c3cdea28 c4e43378 00000000 4385fba4 44224024 c4145936 00000000 435e4c43 43a49d73 42d3e418 00000000 438636f8 43ed8fd2 450d12ed 00000000 4210bc80 c2dcc99b 42c41aba 00000000 4299b2ee c50fdfe9 c44262e4 00000000 42fe5060 c3dac9b9 c50be58c 00000000 42f0366f 44ca1d48 c4ae6032 00000000 4382f138 4475cf34 43adfc47 00000000 420467d2 4383e7ac 44b69e10 00000000 42939fb6 c4a5b2bc 448ad7eb 00000000 41f43fa8 c461b848 c3a71faa 00000000 434a4234 c358c6a6 c4a37e3e 00000000 423c3b16 42ae5790 c28ddcb8 00000000 435f71b1 44586d7a 43a75f50 00000000 431dfe64 43126575 44726ffe 00000000 42e181b4 c3da9fe9 43acb878 00000000 436eefe3 c4efcdc3 c4416fe4 00000000 423c53dc c344a113 c4b46166 00000000 435ffd97 44553a65 c4238580 00000000 430092b5 4499e952 440156a4 00000000 430fbb25 42c9a36d 444f404d 00000000 4369e56c c43b5358 440b6b0e 00000000 41cf9cce c5087fe5 c46ebaa1 00000000 42caf529 c21191e4 c3aa0683 00000000 4344d73b 443ee95e c409dbcb 00000000 41f53bb8 44f6e532 44606df7 00000000 42aa10aa 433460d1 44f3d985 00000000 43582457 c48b83c0 44436f9c 00000000 4350ab77 c4098067 c381cd3a 00000000 41ad0134 c32c4d09 c50a24e7 00000000 4293fef2 44bb391d c47e4d73 00000000 432790ce 44ec7b40 4467a0ac 00000000 43470d71 420e374b 440c07c0 00000000 425deb6d c4911239 443effdb 00000000 4366654f c43619f1 c3b8eb31 00000000 434e068d c2a05b66 c4cc6faf 00000000 4291eb66 44b5dd54 c468003e 00000000 42acbd25 44cd3c28 4457e72d 00000000 433ac26f 41e50db8 444eeade 00000000 431d0e95 c3beb1b5 436b9aef 00000000 435bf745 c4afd8d7 c43f8161 00000000 435bc798 c10e3dc0 c3dc6413 00000000 42e0327b 448376b9 c41d3ced 00000000 439027f4 42ccb7fb 4266a7eb 00000000 42561b63 4107706a 44b7ae4c 00000000 4386c984 c49102da 4427d07c 00000000 43041b2c c48de2f6 c4254afa 00000000 42dc4d73 417fe3ca c4e73dfa 00000000 436e328f 44fdd280 c48e0708 00000000 435a40e7 44dfcd51 4486b7c0 00000000 437af18e c22ffde1 44ee8d5d 00000000 42ebac6a c445a9ae 43d5c9a6 00000000 43543326 c45fbee4 c40b1d1a 00000000 43507e5b 425680ee c4b2d793 00000000 432a871c 45066678 c48c65e4 00000000 438c4680 44501288 44059013 00000000 435e3b80 c2e08f5e 45072ba4 00000000 428609a6 c4a3143d 44246ade 00000000 4395e45b c4556a6b c40d5fe9 00000000 4321ff85 4206eb23 c3fe1841 00000000 42ab0018 43addb6e c3290c04 00000000 4308e930 43dc68f5 43969f8a 00000000 430610e6 c2126c9e 43e25da0 00000000 42ae86ef c4c4feae 4438935d 00000000 42ef05dd c486c228 c43def39 00000000 438f4904 4321a791 c4d3da54 00000000 433ab2f6 44c00026 c42d31f8 00000000 42db619b 44f338d9 44b0b3b2 00000000 42c2bff2 c2dd8512 447bddda 00000000 4313918c c505ea13 44685e96 00000000 4372ce6f 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 0000007b 43820000 3f333333 3fcccccd 42f00000 3eb33333 3f19999a 3f4ccccd 3f800000 -> 3f1796ad 3f2abc83 3f147ad8 3f6031e3" "c4b3e76c 444241a0 42500000 435b9579 4136dbcf 00000000 43945431 43041651 43830dcc 00000000 43652355 c48bf352 44d33444 00000000 434dc873 c3000002 c10833d3 00000000 433550ff c28b6e94 c30f7278 00000000 438d43a6 44a4de09 c4f12ff1 00000000 425a0d22 44d428d3 430976c5 00000000 434b973e 441d5713 44a7fc0b 00000000 438e7c40 c4ab19d7 44f2b987 00000000 41e038cc c455d2d5 c2a36170 00000000 43908b8d c3801d60 c40e138a 00000000 431363cc 43396792 c37f2156 00000000 437ac80c 439f85f6 420c7863 00000000 42150b58 4428e36e 44c2bb96 00000000 43837958 c47c7670 44a88b01 00000000 4294d48d c40641aa c285e8ba 00000000 4232d05c c40ca53f c4a8ca3a 00000000 4378763b 42cfefd3 c306ba40 00000000 430371e3 44b979fd 434ebfb9 00000000 43421a7a 42b44286 43616d19 00000000 43047467 c2892c2a 42ac8e8b 00000000 4332087f c4f373b1 c39604df 00000000 42b3908c c4071e02 c4b04c5b 00000000 4347459e 43fd006f c41a827e 00000000 4208fda6 44c75602 43869ead 00000000 42421b44 4351a83b 440ee820 00000000 42cc9909 c48345c8 449bb4d1 00000000 42125362 c4abe6d8 c37c9d2e 00000000 433a98a6 c43a8ecd c5050f9b 00000000 42b796d8 4488f948 c49dcc59 00000000 43638b9c 4518e99d 43f2fa58 00000000 4271681e 4436363e 450837cb 00000000 437c8686 c49f0216 44b1f01c 00000000 438026fe c4af2be6 c395b304 00000000 43814487 c3f349af c4befb18 00000000 428c07fa 4424afd0 c4330845 00000000 42decd7b 4513cf17 44073fe5 00000000 438a0efc 442d8bbd 450f5844 00000000 434b07a5 c493ef20 449c3dfb 00000000 437f587e c4222d07 c31e43fc 00000000 43128220 c4066f1d c4ea31b9 00000000 4340306c 43e81d81 c3ee2e59 00000000 4330577c 4514ae2d 441a3491 00000000 43869616 439ae709 448ea55a 00000000 43226a3e c4068e80 440626bb 00000000 430d975e c49c9b56 c3ac1805 00000000 42dea520 c4161f98 c5129034 00000000 42da3948 44400c36 c43a07ca 00000000 43826b94 44c6d8dc 43e6e324 00000000 438edae5 4396e0d8 449ca83c 00000000 43643dd5 c35d19fc 4350154a 00000000 430efc77 c485b6e4 c3a3a64d 00000000 4312ff68 c3cdea28 c4e43378 00000000 4385fba4 44224024 c4145936 00000000 435e4c43 43a49d73 42d3e418 00000000 438636f8 43ed8fd2 450d12ed 00000000 4210bc80 c2dcc99b 42c41aba 00000000 4299b2ee c50fdfe9 c44262e4 00000000 42fe5060 c3dac9b9 c50be58c 00000000 42f0366f 44ca1d48 c4ae6032 00000000 4382f138 4475cf34 43adfc47 00000000 420467d2 4383e7ac 44b69e10 00000000 42939fb6 c4a5b2bc 448ad7eb 00000000 41f43fa8 c461b848 c3a71faa 00000000 434a4234 c358c6a6 c4a37e3e 00000000 423c3b16 42ae5790 c28ddcb8 00000000 435f71b1 44586d7a 43a75f50 00000000 431dfe64 43126575 44726ffe 00000000 42e181b4 c3da9fe9 43acb878 00000000 436eefe3 c4efcdc3 c4416fe4 00000000 423c53dc c344a113 c4b46166 00000000 435ffd97 44553a65 c4238580 00000000 430092b5 4499e952 440156a4 00000000 430fbb25 42c9a36d 444f404d 00000000 4369e56c c43b5358 440b6b0e 00000000 41cf9cce c5087fe5 c46ebaa1 00000000 42caf529 c21191e4 c3aa0683 00000000 4344d73b 443ee95e c409dbcb 00000000 41f53bb8 44f6e532 44606df7 00000000 42aa10aa 433460d1 44f3d985 00000000 43582457 c48b83c0 44436f9c 00000000 4350ab77 c4098067 c381cd3a 00000000 41ad0134 c32c4d09 c50a24e7 00000000 4293fef2 44bb391d c47e4d73 00000000 432790ce 44ec7b40 4467a0ac 00000000 43470d71 420e374b 440c07c0 00000000 425deb6d c4911239 443effdb 00000000 4366654f c43619f1 c3b8eb31 00000000 434e068d c2a05b66 c4cc6faf 00000000 4291eb66 44b5dd54 c468003e 00000000 42acbd25 44cd3c28 4457e72d 00000000 433ac26f 41e50db8 444eeade 00000000 431d0e95 c3beb1b5 436b9aef 00000000 435bf745 c4afd8d7 c43f8161 00000000 435bc798 c10e3dc0 c3dc6413 00000000 42e0327b 448376b9 c41d3ced 00000000 439027f4 42ccb7fb 4266a7eb 00000000 42561b63 4107706a 44b7ae4c 00000000 4386c984 c49102da 4427d07c 00000000 43041b2c c48de2f6 c4254afa 00000000 42dc4d73 417fe3ca c4e73dfa 00000000 436e328f 44fdd280 c48e0708 00000000 435a40e7 44dfcd51 4486b7c0 00000000 437af18e c22ffde1 44ee8d5d 00000000 42ebac6a c445a9ae 43d5c9a6 00000000 43543326 c45fbee4 c40b1d1a 00000000 43507e5b 425680ee c4b2d793 00000000 432a871c 45066678 c48c65e4 00000000 438c4680 44501288 44059013 00000000 435e3b80 c2e08f5e 45072ba4 00000000 428609a6 c4a3143d 44246ade 00000000 4395e45b c4556a6b c40d5fe9 00000000 4321ff85 4206eb23 c3fe1841 00000000 42ab0018 43addb6e c3290c04 00000000 4308e930 43dc68f5 43969f8a 00000000 430610e6 c2126c9e 43e25da0 00000000 42ae86ef c4c4feae 4438935d 00000000 42ef05dd c486c228 c43def39 00000000 438f4904 4321a791 c4d3da54 00000000 433ab2f6 44c00026 c42d31f8 00000000 42db619b 44f338d9 44b0b3b2 00000000 42c2bff2 c2dd8512 447bddda 00000000 4313918c c505ea13 44685e96 00000000 4372ce6f 459b0000 c5688000 40e00000 43550000 459c4000 c56a6000 40e00000 43560000 459d8000 c56c4000 40e00000 43570000 459ec000 c56e2000 40e00000 43580000 45a00000 c5700000 40e00000 43590000 0000007b 43820000 3f333333 3fcccccd 42f00000 3eb33333 3f19999a 3f4ccccd 3f800000 -> 3f1796ad 3f2abc83 3f147ad8 3f6031e3"
] ]
},
{
"glsl": "float cultmath_fbm(vec3, int, float, float)",
"tolerance": "ulp-bounded",
"cases": [
"c11a9d90 c04202a0 c20bc826 00000004 40000000 3f000000 -> 3f27d6ca",
"41601e80 4005e0a0 41eba008 00000004 40000000 3f000000 -> be8cf3fd",
"407e0d30 c181cd2c 41cbe660 00000004 40000000 3f000000 -> 3eebda0d",
"41b7f3d4 c129244c c0fdb2f0 00000004 40000000 3f000000 -> bd3ada1f",
"407005a0 4118392c 41a44bb4 00000004 40000000 3f000000 -> beabbce1",
"c1830e64 c2048dab 422a3126 00000004 40000000 3f000000 -> 3e53b796",
"407894c0 c2091ce8 4243e388 00000004 40000000 3f000000 -> beff701d",
"c1f06795 c23c1b2b c2412342 00000004 40000000 3f000000 -> 3ec7b869",
"c1bdbda4 41dc0d48 c22f43c4 00000004 40000000 3f000000 -> bec760f6",
"408126b0 c1ea0445 c1a7655d 00000004 40000000 3f000000 -> 3e0a4e06",
"413dbdcc 422bc160 40f21a68 00000004 40000000 3f000000 -> bea53f12",
"c1484ebc 420439be 416f96b0 00000004 40000000 3f000000 -> 3e72cffa",
"c1ba67c0 408252b0 421a9588 00000004 40000000 3f000000 -> bf451a7c",
"c16b1c64 41a06cd4 c176d898 00000004 40000000 3f000000 -> 3f02099c",
"4120d64c 41de56d4 40b8cd88 00000004 40000000 3f000000 -> 3f2bebc0",
"c1ca675a c230dc20 c22de33d 00000004 40000000 3f000000 -> 3f1cd196",
"4137ed30 4146d32c c21ce93a 00000004 40000000 3f000000 -> 3da3b336",
"c08f7cb0 4226b458 41db68b4 00000004 40000000 3f000000 -> be182486",
"41f4cfe4 c1ece732 c0c027f0 00000004 40000000 3f000000 -> 3daec608",
"c212a4e3 c1446638 41c4aed0 00000004 40000000 3f000000 -> be06a746",
"41ba2790 4247bbfe c213e474 00000004 40000000 3f000000 -> 3f08da39",
"c065a220 c21b758b c060a740 00000004 40000000 3f000000 -> 3f0948cb",
"420a0656 c206ebf6 41d3a918 00000004 40000000 3f000000 -> be0f2d50",
"c231bfd1 c22c5352 414c9ffc 00000004 40000000 3f000000 -> 3d5ad4f0",
"c2386461 c1fb5f35 c08a9a88 00000004 40000000 3f000000 -> bf0307b4",
"416666b8 411e5a8c c1c68d2b 00000004 40000000 3f000000 -> bf3c2a7e",
"c081e798 c241ab24 41adc588 00000004 40000000 3f000000 -> 3ecf6bca",
"42469f7a 422a298a 41052448 00000004 40000000 3f000000 -> bd9e0e93",
"c1fd92e8 c206a0f6 c242f2f6 00000004 40000000 3f000000 -> bea9dec8",
"41f11de8 40dc22f8 414ab55c 00000004 40000000 3f000000 -> bd8bc474",
"c1e82aab c2128efd c2069c1b 00000004 40000000 3f000000 -> be7c0319",
"c20fc1e0 41409478 41a0f4d8 00000004 40000000 3f000000 -> 3da2f1d0",
"c1e3a366 c211704c 418541ec 00000004 40000000 3f000000 -> 3f3968b5",
"c222dbdb c11ce808 421e0fd6 00000004 40000000 3f000000 -> 3eb06c7c",
"c19f6f92 40bdd8e0 40b040e8 00000004 40000000 3f000000 -> 3d4ea228",
"c0011390 c1062cd4 41b8c7e4 00000004 40000000 3f000000 -> bed7e250",
"c191f0aa c20731bd 4127d710 00000004 40000000 3f000000 -> 3e0d1a30",
"c1f97cd1 418b3020 c15db9a8 00000004 40000000 3f000000 -> 3dfd58fc",
"c23c8b5e 41bce8a0 c22a6d8e 00000004 40000000 3f000000 -> 3c465804",
"409693d0 c1af215b 42332b6c 00000004 40000000 3f000000 -> bee9b32d",
"c098bbf0 c236889e 41163eac 00000004 40000000 3f000000 -> 3f0bd913",
"c210d8cb 420a5584 422e3bac 00000004 40000000 3f000000 -> bebb6492",
"c063be60 421f5d7e 40d15a40 00000004 40000000 3f000000 -> 3ebd15a8",
"41cc6488 c0e4a4f8 c06f9100 00000004 40000000 3f000000 -> 3db49ff4",
"410861d0 c155845c 41363b78 00000004 40000000 3f000000 -> bdcb6293",
"4200d2ee 419cae84 c1564644 00000004 40000000 3f000000 -> 3edb892a",
"c23cbf0a 41a28f8c 41c5d480 00000004 40000000 3f000000 -> bf2d47fb",
"c1aeadcf bfcf1700 c1152790 00000004 40000000 3f000000 -> be5972ba",
"c23f486c c23f56ed c0813868 00000004 40000000 3f000000 -> 3ee6dc4a",
"41f1a4a0 c21b3682 4219b0ce 00000004 40000000 3f000000 -> bf0eadc6",
"414b9fe8 4186b7a4 420e2062 00000004 40000000 3f000000 -> 3e2980bf",
"41aeeb18 4160ee88 c162c594 00000004 40000000 3f000000 -> bee70d01",
"c19effaa c206defe 41aacb30 00000004 40000000 3f000000 -> 3de761d6",
"413f31c0 c241d553 3fe2e9a0 00000004 40000000 3f000000 -> bde2b38a",
"c205c755 40c6ab38 41bac5c0 00000004 40000000 3f000000 -> 3ed8ad73",
"c20f112c 4124a490 c19a0db2 00000004 40000000 3f000000 -> bdc30b1c",
"41c60570 c1eabc98 c237e2f2 00000004 40000000 3f000000 -> bf4faa5b",
"41a64804 3ff230a0 40f93c68 00000004 40000000 3f000000 -> bee98cca",
"c1ef7da7 c203c097 40bf80d8 00000004 40000000 3f000000 -> 3db61265",
"c1fa4ce3 c219905e 4201ac72 00000004 40000000 3f000000 -> bf387f6a",
"c019d480 4181e398 41e52800 00000004 40000000 3f000000 -> be033562",
"4190f4f4 c214b5b7 41d01f1c 00000004 40000000 3f000000 -> 3d5e261c",
"412b9a38 4212a2e8 41b4c68c 00000004 40000000 3f000000 -> 3f336c18",
"c1c37f7b 40052c30 405363f0 00000004 40000000 3f000000 -> 3e9fce4c",
"41742360 c1122dec 41963a2c 00000004 40000000 3f000000 -> 3d369949",
"c1d85a1d c2198f93 4228b1e0 00000004 40000000 3f000000 -> 3e151611",
"418aa674 4217c8c0 c1020040 00000004 40000000 3f000000 -> 3d9e5382",
"c20b2034 c232306a 422227ca 00000004 40000000 3f000000 -> 3d21dba6",
"420993c0 4227c3f8 c20c35b2 00000004 40000000 3f000000 -> 3e254db8",
"c1b20e56 3fe68be0 c0403c60 00000004 40000000 3f000000 -> 3e00593c",
"41eebd00 c21a5963 c2354e9f 00000004 40000000 3f000000 -> bf023291",
"421bb928 c20e9648 c16b1c78 00000004 40000000 3f000000 -> bf0b8dcb",
"41b90698 bfc839c0 c0d3a708 00000004 40000000 3f000000 -> bf1723c8",
"423d5354 40608460 41bd0c74 00000004 40000000 3f000000 -> bcc26220",
"41025788 bf4f6280 41949de4 00000004 40000000 3f000000 -> beed4e99",
"3f39b3c0 c231e4d4 c21be301 00000004 40000000 3f000000 -> be0bb9b2",
"421c0a76 423c6e3a c2323104 00000004 40000000 3f000000 -> bf40bdcd",
"415cf8ec 3fb0caa0 c1d2b23c 00000004 40000000 3f000000 -> be851f93",
"c1174dfc c18cfa28 412c88d4 00000004 40000000 3f000000 -> 3f111826",
"42186c38 4206e6ba c1e8f35c 00000004 40000000 3f000000 -> bec21f9a",
"41311500 c188248e c17245d8 00000004 40000000 3f000000 -> 3d6acd72",
"c1c331e9 c088f1f0 c1154f68 00000004 40000000 3f000000 -> 3f32b2ec",
"bfaf6440 41d29f60 c1bbea67 00000004 40000000 3f000000 -> 3d96329a",
"c1843796 be305a00 4152c8e8 00000004 40000000 3f000000 -> bdce91e5",
"bf9b27e0 419fffcc c178fe1c 00000004 40000000 3f000000 -> 3e94636d",
"4191f36c 40e24f50 40c86908 00000004 40000000 3f000000 -> beb06eb9",
"c1e04e4e c182a61e 4211a8cc 00000004 40000000 3f000000 -> 3e4f51ee",
"c23852b1 c1344684 40963120 00000004 40000000 3f000000 -> 3f06fbc7",
"c1074b08 c207dfba 41208124 00000004 40000000 3f000000 -> 3f44398c",
"41f81500 bfce6b20 41ffcd98 00000004 40000000 3f000000 -> 3db207ed",
"41c42d58 420c1714 416b5de8 00000004 40000000 3f000000 -> be56c8b1",
"c1dbf666 c15bc7b4 c1ebcae6 00000004 40000000 3f000000 -> be987233",
"c133a140 c18ba254 c23a1f4c 00000004 40000000 3f000000 -> be6063b7",
"c1b0ba6b c23f9e0f c1a66906 00000004 40000000 3f000000 -> 3e71480d",
"420a1232 40b13200 c02e16b0 00000004 40000000 3f000000 -> bda67ee4",
"c0f719f8 c20be886 41fb0cd8 00000004 40000000 3f000000 -> be9018c6",
"421ce668 41b26594 41ca7208 00000004 40000000 3f000000 -> 3f33ea18",
"c235eaec 418126bc 3d672000 00000004 40000000 3f000000 -> bea45437",
"c17889e4 40e4bf88 4230ff5a 00000004 40000000 3f000000 -> beb68251",
"41f150cc 420839ce c1a27d07 00000004 40000000 3f000000 -> bcbd7004",
"41f517bc 418cf4bc c1b079ee 00000004 40000000 3f000000 -> bb24d74c",
"c0aedff8 410f3624 416ce240 00000004 40000000 3f000000 -> 3f0d6ce0",
"4241dac2 c1bdbc18 c1821c58 00000004 40000000 3f000000 -> bea85f48",
"c2105a14 c1a43bf4 c1cbc9d9 00000004 40000000 3f000000 -> 3ebe4d06",
"4032c6d0 42303610 c19369ff 00000004 40000000 3f000000 -> 3ee7e743",
"40e6ea70 c1f4c9c7 4204c81e 00000004 40000000 3f000000 -> be98d471",
"c22a5870 4245107c c243f735 00000004 40000000 3f000000 -> bed2576b",
"41e533b8 42272418 41d0b3b4 00000004 40000000 3f000000 -> 3e0d2bde",
"c22d5237 41a00334 c1f8f891 00000004 40000000 3f000000 -> 3e1a4a1f",
"c20d624e 42182746 41e8ec44 00000004 40000000 3f000000 -> bd73af1a",
"c1bdc99a 4109bd28 bfc15ee0 00000004 40000000 3f000000 -> bdd4a46b",
"41be3e70 c02e9130 c17861bc 00000004 40000000 3f000000 -> be9a49dc",
"c242f78d c1204738 423a58b8 00000004 40000000 3f000000 -> bd8f7f9e",
"4201e604 c0c5f000 421af586 00000004 40000000 3f000000 -> bd939ee0",
"c016b680 41ca80f8 c0e579d8 00000004 40000000 3f000000 -> be949954",
"3f477c00 3fdd1520 41b0ba9c 00000004 40000000 3f000000 -> 3f61641f",
"c1fc2271 41da0074 c1440cb0 00000004 40000000 3f000000 -> 3d22d4bd",
"c21bc8c4 41b601ac 420b2952 00000004 40000000 3f000000 -> bea1ff75",
"c1aa8e9f c2400dfe 41ceac90 00000004 40000000 3f000000 -> bf0ee9ae",
"3fed25c0 c1422724 421bcbf0 00000004 40000000 3f000000 -> 3e7b8260",
"c0825938 42073d10 c13c1548 00000004 40000000 3f000000 -> 3e8108b9",
"4093dd90 42168aba 41a6e578 00000004 40000000 3f000000 -> bf70dcc5",
"41d04c14 41e81a68 c04e0de0 00000004 40000000 3f000000 -> 3f169ba9",
"423c6370 40bea6d8 41eb59d4 00000004 40000000 3f000000 -> be27a3b6",
"42126f66 c1c87ce9 40ae73e0 00000004 40000000 3f000000 -> bdecf67a",
"c216ecbb c197f579 c2061c65 00000004 40000000 3f000000 -> bec0f5a1",
"c185edd2 40bc7ec8 c1fdce1e 00000004 40000000 3f000000 -> beaeea8a",
"c04d9980 4238aeec c21cd8f3 00000004 40000000 3f000000 -> bf4c5f9d",
"c10a1ac8 c20b1c08 420f6e74 00000004 40000000 3f000000 -> bca013bc",
"415b2edc c09ccbc8 c096d8b0 00000004 40000000 3f000000 -> be5536d7",
"410a4524 bf182e80 c23f8806 00000004 40000000 3f000000 -> 3ee6c4f3",
"c2297336 c1573b54 412013c4 00000004 40000000 3f000000 -> 3c3ab7a0",
"3efd6c00 422c18e8 4216b5c2 00000004 40000000 3f000000 -> beeca0ab",
"41afbc10 41c5efcc 41b5b6e8 00000004 40000000 3f000000 -> bf364b64",
"c1c583a7 c1e4e94f c1e5a945 00000004 40000000 3f000000 -> bf34f4f8",
"41053fa4 c1cc7056 c00c0b50 00000004 40000000 3f000000 -> bdda9df2",
"c1a54a53 c2065a94 41a7d97c 00000004 40000000 3f000000 -> 3e6ae330",
"4115cab4 c1fb3fd3 41ffccbc 00000004 40000000 3f000000 -> be2050ce",
"41a565fc 42448e70 42168e26 00000004 40000000 3f000000 -> bd95b845",
"c1b458be c212fd9c 41b34d84 00000004 40000000 3f000000 -> bde52094",
"420c044a c216a5ac c180b2a6 00000004 40000000 3f000000 -> 3ea3b257",
"c24205c8 c138b768 c22dca6a 00000004 40000000 3f000000 -> bf026e60",
"41da69e0 41e7c53c 42199060 00000004 40000000 3f000000 -> 3e296457",
"4235c98a 413c61b4 c1404e8c 00000004 40000000 3f000000 -> bd267211",
"c1a9a44f 4134ac38 41f92fec 00000004 40000000 3f000000 -> bd77a9b6",
"c08e1418 c1e42f89 41c64724 00000004 40000000 3f000000 -> 3f071a60",
"421dfbb0 42042964 42231c22 00000004 40000000 3f000000 -> be057b84",
"c13f435c c17a3c80 c1aa7dc8 00000004 40000000 3f000000 -> bece20fe",
"41c27058 42451e9c c19981a2 00000004 40000000 3f000000 -> be79305d",
"c1eedcc7 c15098d0 c1497518 00000004 40000000 3f000000 -> be9e1b90",
"41b2ed98 c1d7b191 41ee2d04 00000004 40000000 3f000000 -> 3d44b899",
"c20af100 4213d9c0 41467648 00000004 40000000 3f000000 -> 3ec0aa40",
"c1332ce4 c1d17816 4203b932 00000004 40000000 3f000000 -> be11a895",
"c20d0a38 41f835f0 c090d638 00000004 40000000 3f000000 -> be1498a4",
"4116bbac c180dd68 409467f0 00000004 40000000 3f000000 -> bf0fd158",
"c187ff3c c19b7655 40e9a840 00000004 40000000 3f000000 -> 3e89df12",
"c244b197 c1c714ff 41dd4c6c 00000004 40000000 3f000000 -> 3ead3f11",
"c214956d c140a03c c19e4e02 00000004 40000000 3f000000 -> 3ef4d00f",
"41fd1540 c042ae00 41dfd2e8 00000004 40000000 3f000000 -> 3d973e78",
"c2331a6e c1b67be9 c12d31d4 00000004 40000000 3f000000 -> be750957",
"41cdeccc 40e51a98 c0ffb978 00000004 40000000 3f000000 -> be253870",
"3f7b69c0 c1d75a82 c1343b2c 00000004 40000000 3f000000 -> 3dbf25d2",
"41035764 c211e128 41c2a2a0 00000004 40000000 3f000000 -> 3f151eee",
"c1fa0b38 c22cf74a 423ea448 00000004 40000000 3f000000 -> bd6e638c",
"42276a60 41c60bd0 c23ff195 00000004 40000000 3f000000 -> bf359aaa",
"c09648b8 41e5007c c246206c 00000004 40000000 3f000000 -> 3ec9a87b",
"41c06838 c10b20d4 4195b38c 00000004 40000000 3f000000 -> be8ff22f",
"41bc1450 c1f57998 c2189bb5 00000004 40000000 3f000000 -> 3e28b2f1",
"411bf210 411830e4 420c4f46 00000004 40000000 3f000000 -> be20e42c",
"bee13d80 c182f740 c11fed84 00000004 40000000 3f000000 -> bf6b6b32",
"4209b41c c1836194 423a81ea 00000004 40000000 3f000000 -> 3eebe724",
"c0c7d2c8 c1300278 c1d110b4 00000004 40000000 3f000000 -> 3edf09dc",
"c23a2398 c2263ebc c21cc173 00000004 40000000 3f000000 -> 3f347bc5",
"c2086436 41c65514 c203da03 00000004 40000000 3f000000 -> be4a016a",
"4203e3b8 42005406 c1fd266b 00000004 40000000 3f000000 -> 3e92d85b",
"41f003dc c1e7ff9b 41534824 00000004 40000000 3f000000 -> bf51dab7",
"c1ae6ad8 c15ab124 4192a9c8 00000004 40000000 3f000000 -> 3ea4064b",
"41dc13c0 c1969d16 420f8a40 00000004 40000000 3f000000 -> 3f0909f0",
"41a3b388 c23f13c1 c1427f80 00000004 40000000 3f000000 -> 3e6ede51",
"c19eb6f5 4224f120 41591a18 00000004 40000000 3f000000 -> 3e6ab4d3",
"406a1ef0 c22567ab 41ef34f0 00000004 40000000 3f000000 -> bf055e0e",
"c17acd40 c17ae6e4 41b97304 00000004 40000000 3f000000 -> be66940a",
"c243dda3 bff368e0 c163e5e4 00000004 40000000 3f000000 -> bd501bea",
"41e12d8c c10295fc 41a17df0 00000004 40000000 3f000000 -> 3dd3be72",
"421ea106 4102614c 41841a84 00000004 40000000 3f000000 -> bf09bbcc",
"3fd67940 42222670 c14c4344 00000004 40000000 3f000000 -> 3ee83191",
"422df782 41edf52c c137cfe8 00000004 40000000 3f000000 -> 3d22e603",
"c1e10584 c1e51a20 423d30aa 00000004 40000000 3f000000 -> 3f0369ee",
"41ad5f78 c22c55c3 c23e623f 00000004 40000000 3f000000 -> bd8b828e",
"c21d0856 41ba57c4 c153b374 00000004 40000000 3f000000 -> bf83804f",
"42101786 41ba6fc4 c2032fdd 00000004 40000000 3f000000 -> 3e0487d8",
"c0307f90 4143e76c 41812468 00000004 40000000 3f000000 -> bf30ffd4",
"c14dd1ec bfad1f00 c00f2420 00000004 40000000 3f000000 -> bdccd9c1",
"c09a65b8 41e7bf24 41b736f4 00000004 40000000 3f000000 -> 3f093490",
"c1b468fb 3d4f3800 4231f934 00000004 40000000 3f000000 -> 3c21555a",
"424095ea 421768d8 41b83244 00000004 40000000 3f000000 -> bece4789",
"c1f50604 c1f9b40a c2193a31 00000004 40000000 3f000000 -> be4a50c7",
"c2458f10 c1554bf0 c21a0974 00000004 40000000 3f000000 -> bf1acbb9",
"c1c78b00 c191b69e 42152566 00000004 40000000 3f000000 -> be957456",
"420269ce 414a73d0 c1b4c7ed 00000004 40000000 3f000000 -> 3e697510",
"41a63780 42212ab4 41c671ac 00000004 40000000 3f000000 -> 3f139976",
"4243f362 42287f3e 4194453c 00000004 40000000 3f000000 -> 3c50a516",
"403eed70 c0f2b800 42379e78 00000004 40000000 3f000000 -> beba9818",
"c24684f1 c20dd7bc c101c264 00000004 40000000 3f000000 -> 3d3cca45",
"421de172 bfd77900 c244e4fc 00000004 40000000 3f000000 -> 3f050db9",
"4179c230 4231b1ba 41aa7e40 00000004 40000000 3f000000 -> 3e3e0e04",
"c23cc88c 41bc33a8 c1f77d37 00000004 40000000 3f000000 -> 3d7074f3",
"c1a4f6c3 42257aac 40902f38 00000004 40000000 3f000000 -> 3cded1fb",
"c1c76558 420da696 c155d488 00000004 40000000 3f000000 -> bdb2d8a1",
"c2312669 c1de84f2 c19c55ac 00000004 40000000 3f000000 -> bf733e95",
"41e6d99c 40910dc0 420ffa14 00000004 40000000 3f000000 -> be87cefb",
"c1ed1c98 41c32f10 421ab4de 00000004 40000000 3f000000 -> beac3f5c",
"424062a4 c1ac4fc1 40fda488 00000004 40000000 3f000000 -> bed84bad",
"c1c052d0 c09ac768 c0920138 00000004 40000000 3f000000 -> 3e8a6d70",
"41d8f554 c1f7fc22 c2370249 00000004 40000000 3f000000 -> 3eb1ddd1",
"419e6cd0 c21b7082 421968ce 00000004 40000000 3f000000 -> be530987",
"c21d2479 411727cc c14a3428 00000004 40000000 3f000000 -> 3dd46038",
"c20819bc 41ee64b4 421c8320 00000004 40000000 3f000000 -> bdbaa7c4",
"c226a55b c109a378 4243b008 00000004 40000000 3f000000 -> 3da8d91f",
"c1ed9a23 41bf4c20 c22d2758 00000004 40000000 3f000000 -> be4a641a",
"c190d081 c02eac10 c1ee290f 00000004 40000000 3f000000 -> 3e49c3d4",
"41772540 c1e3e37f 41201a34 00000004 40000000 3f000000 -> be5c48b4",
"40bcaa98 4198df7c 41bf595c 00000004 40000000 3f000000 -> 3f1b99b9",
"c21799da 418c1bd0 417f6670 00000004 40000000 3f000000 -> bd1b2a24",
"c2127570 41d6c1b8 c1ad1a80 00000004 40000000 3f000000 -> 3d032504",
"41bd0318 40109f50 41b2f178 00000004 40000000 3f000000 -> be738350",
"3e841880 40a8b758 41c22e30 00000004 40000000 3f000000 -> be993b38",
"bfdbbdc0 4233fe10 414bce50 00000004 40000000 3f000000 -> be35bd5c",
"422c6234 414e1a28 41c5deb8 00000004 40000000 3f000000 -> 3eaa7016",
"423b76f4 c217e8f1 c187ff32 00000004 40000000 3f000000 -> 3ec9b4b3",
"421f04d6 41de5f6c 40919080 00000004 40000000 3f000000 -> be522d95",
"3f9b7d20 42303ed8 423f91d4 00000004 40000000 3f000000 -> beed15a0",
"c1c65740 c2194ea5 423df3ae 00000004 40000000 3f000000 -> be9b386d",
"c0b846b8 c2105eea c0ff01f8 00000004 40000000 3f000000 -> be6e4c9a",
"c20e43e2 42427560 4227bc0c 00000004 40000000 3f000000 -> 3eb83eb6",
"41ab32b4 c23da44d c23c2e83 00000004 40000000 3f000000 -> bf1c83e6",
"412c4184 c1749b9c 421c8d2c 00000004 40000000 3f000000 -> be315e50",
"c1bb9168 41a0557c c1fae1b8 00000004 40000000 3f000000 -> be39850e",
"c18c7cde 420675ae 401172f0 00000004 40000000 3f000000 -> bec5ccdb",
"40efbf28 41b2ce34 410a1ed8 00000004 40000000 3f000000 -> bf1e2c8b",
"420d1730 41107978 40e2c578 00000004 40000000 3f000000 -> 3eb26d1a",
"c1114ee0 c1dd3d62 421ede34 00000004 40000000 3f000000 -> 3db35cba",
"c21a5874 4142baa4 42032204 00000004 40000000 3f000000 -> bcff40fb",
"c19c3e28 bfca5a80 c1169d98 00000004 40000000 3f000000 -> bf81fcc7",
"41c70a18 c22feef8 c10a28ac 00000004 40000000 3f000000 -> bea49aff",
"c18e1152 c181341a 423988ae 00000004 40000000 3f000000 -> 3e720a62",
"41a66f10 c1e26e4d 4124ab60 00000004 40000000 3f000000 -> 3e3bbee8",
"41ae3bc0 c15e65b4 41da7bec 00000004 40000000 3f000000 -> 3e0672ef",
"c0c46630 4153362c c228fb08 00000004 40000000 3f000000 -> bf217d87",
"c1617454 c1f9cb51 c21e36d6 00000004 40000000 3f000000 -> be923997",
"c08c2ae0 418db8f4 c12ad700 00000004 40000000 3f000000 -> bef08fdd",
"c1325020 410f7fe4 409c15b8 00000004 40000000 3f000000 -> be423c5a",
"420bc714 41bc1fb0 41d22824 00000004 40000000 3f000000 -> 3e0456ea",
"c23f2228 411fc738 c1194bd4 00000004 40000000 3f000000 -> 3b981998",
"c1eea110 c19da117 42112bee 00000004 40000000 3f000000 -> beafb6e7",
"bfb96dc0 3fd647a0 416066d8 00000004 40000000 3f000000 -> be699b27"
]
} }
] ]
} }

View file

@ -0,0 +1,92 @@
// Measures the Lipschitz constant of math.phacelle's (cosine, sine) field, the number docs/design.md quotes and
// PhacelleTests.GradientNormStaysUnderTheDocumentedLipschitzEnvelope pins.
// dotnet run -c Release --project packages/cultmath/tools/phacelle-lipschitz -- <samples per cell> <normalizations> <side lengths>
// e.g. ... -- 4000000 0,0.5,0.75,0.9 0.25,0.5,1,1.5,2,4,8,12.5,25
// Columns: sampledMax (random p), climbedMax (hill-climbed from the 256 best samples; the number to quote), secantMax
// (value differences around the climbed argmax, the check that the analytic gradient is the field's slope),
// scalarMax (the larger of |grad cosine|, |grad sine| over the samples), p99.99 (the typical tail), and climbed*(1-n)/|side|.
using System;
using System.Linq;
using System.Threading.Tasks;
using CultMath;
// Lipschitz bound of math.phacelle's (cosine, sine) field: sup over p of the operator norm of its 2x3 Jacobian,
// found by (1) random sampling of the exact analytic gradient, (2) hill-climbing from the top samples over
// (p, off, side direction), (3) a secant check on the VALUES (|f(p+d) - f(p)| / |d|) so the gradient itself is not trusted.
static double Op(float3 p, float3 side, float off, float n, out double comp)
{
var r = math.phacelle(p, side, off, n);
double a = r.cosine.x*r.cosine.x + r.cosine.y*r.cosine.y + r.cosine.z*r.cosine.z;
double d = r.sine.x*r.sine.x + r.sine.y*r.sine.y + r.sine.z*r.sine.z;
double b = r.cosine.x*r.sine.x + r.cosine.y*r.sine.y + r.cosine.z*r.sine.z;
double tr = a + d, det = a*d - b*b;
comp = Math.Sqrt(Math.Max(a, d));
var op = Math.Sqrt(tr/2 + Math.Sqrt(Math.Max(0, tr*tr/4 - det)));
return double.IsFinite(op) ? op : 0;
}
static float3 Dir(double u, double th, double len) { var s = Math.Sqrt(Math.Max(0, 1-u*u)); return new float3((float)(s*Math.Cos(th)*len), (float)(s*Math.Sin(th)*len), (float)(u*len)); }
float[]? norms = null, sides = null;
try { norms = args.Length == 3 ? args[1].Split(',').Select(float.Parse).ToArray() : null; sides = args.Length == 3 ? args[2].Split(',').Select(float.Parse).ToArray() : null; }
catch (FormatException) { }
if (args.Length != 3 || !long.TryParse(args[0], out var N) || N <= 0 || norms is null || sides is null)
{
Console.Error.WriteLine("usage: phacelle-lipschitz <samples per cell> <normalizations> <side lengths>");
Console.Error.WriteLine(" e.g. phacelle-lipschitz 4000000 0,0.5,0.75,0.9 0.25,0.5,1,1.5,2,4,8,12.5,25");
return 2;
}
Console.WriteLine("n\t|side|\tN\tsampledMax\tclimbedMax\tsecantMax\tscalarMax(cos|sin)\tp99.99\tclimbed*(1-n)/|side|");
foreach (var n in norms) foreach (var sl in sides)
{
int T = Environment.ProcessorCount;
var tops = new (double v, float3 p, double u, double th, float off)[T][];
var allv = new double[T][]; var scal = new double[T];
Parallel.For(0, T, t =>
{
var rng = new System.Random(unchecked(99991 * t + (int)(n * 1000) * 131 + (int)(sl * 100)));
var top = new (double v, float3 p, double u, double th, float off)[16];
var list = new double[N / T];
double sc = 0;
for (long i = 0; i < N / T; i++)
{
var p = new float3((float)(rng.NextDouble()*64-32), (float)(rng.NextDouble()*64-32), (float)(rng.NextDouble()*64-32));
double u = rng.NextDouble()*2-1, th = rng.NextDouble()*Math.PI*2; float off = (float)rng.NextDouble();
var v = Op(p, Dir(u, th, sl), off, n, out var c);
list[i] = v; if (c > sc) sc = c;
int m = 0; for (int k = 1; k < 16; k++) if (top[k].v < top[m].v) m = k;
if (v > top[m].v) top[m] = (v, p, u, th, off);
}
tops[t] = top; allv[t] = list; scal[t] = sc;
});
var all = allv.SelectMany(x => x).ToArray(); Array.Sort(all);
double sampled = all[^1], p9999 = all[(int)(all.Length * 0.9999)];
// hill-climb the best 16*T by random local search
var seeds = tops.SelectMany(x => x).OrderByDescending(x => x.v).Take(256).ToArray();
var climbed = new (double v, float3 p, double u, double th, float off)[seeds.Length];
Parallel.For(0, seeds.Length, s =>
{
var rng = new System.Random(s + 17);
var cur = seeds[s]; double step = 0.05;
for (int it = 0; it < 10000; it++)
{
var q = new float3(cur.p.x + (float)((rng.NextDouble()-.5)*step), cur.p.y + (float)((rng.NextDouble()-.5)*step), cur.p.z + (float)((rng.NextDouble()-.5)*step));
var u = Math.Clamp(cur.u + (rng.NextDouble()-.5)*step, -1, 1); var th = cur.th + (rng.NextDouble()-.5)*step; var off = cur.off + (float)((rng.NextDouble()-.5)*step);
var v = Op(q, Dir(u, th, sl), off, n, out _);
if (v > cur.v) cur = (v, q, u, th, off); else if (it % 1000 == 999) step *= 0.5;
}
climbed[s] = cur;
});
var best = climbed.OrderByDescending(x => x.v).First();
// secant check around the climbed argmax: values only, tiny displacements in random directions
double secant = 0; var sr = new System.Random(5);
var side0 = Dir(best.u, best.th, sl);
for (int i = 0; i < 20000; i++)
{
double e = Math.Pow(10, -2 - 2*sr.NextDouble()); var d = Dir(sr.NextDouble()*2-1, sr.NextDouble()*6.2832, e);
var a = math.phacelle(best.p, side0, best.off, n); var b = math.phacelle(best.p + d, side0, best.off, n);
double dc = b.cosine.w - a.cosine.w, ds = b.sine.w - a.sine.w; var dl = Math.Sqrt((double)d.x*d.x + (double)d.y*d.y + (double)d.z*d.z);
secant = Math.Max(secant, Math.Sqrt(dc*dc + ds*ds) / dl);
}
Console.WriteLine($"{n}\t{sl}\t{N}\t{sampled:F3}\t{best.v:F3}\t{secant:F3}\t{scal.Max():F3}\t{p9999:F3}\t{best.v*(1-n)/sl:F3}");
}
return 0;

View file

@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><AllowUnsafeBlocks>true</AllowUnsafeBlocks><InvariantGlobalization>true</InvariantGlobalization><ServerGarbageCollector>false</ServerGarbageCollector></PropertyGroup>
<ItemGroup><ProjectReference Include="../../src/CultMath/CultMath.csproj" /></ItemGroup>
</Project>

View file

@ -5,6 +5,14 @@ All notable changes to this package are documented in this file.
Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not Earlier releases (0.2.0-0.2.3) predate this package's changelog and are not
backfilled here; see `docs/semver-policy.md` for why. backfilled here; see `docs/semver-policy.md` for why.
## [Unreleased]
### Added
- `math.fbm(float3 p, int octaves, float lacunarity, float gain)` and `math.fbm(float3 p)` (four octaves,
lacunarity 2, gain 0.5): the value-only octave sum of `snoise`, the cost of `fbm_grad` without the gradient.
The shaders carry `cultmath_fbm` in HLSL and GLSL; octaves clamp to [0, 16] as `fbm_grad`'s do.
## [0.5.0] ## [0.5.0]
### Breaking ### Breaking

View file

@ -342,6 +342,26 @@ float4 cultmath_fbm_grad(float3 p, int octaves, float lacunarity, float gain)
return float4(gradient, value); return float4(gradient, value);
} }
// Value-only octave sum of cultmath_snoise; see the C# math.fbm comment. The one-argument form is four octaves,
// lacunarity 2, gain 0.5.
float cultmath_fbm(float3 p, int octaves, float lacunarity, float gain)
{
octaves = clamp(octaves, 0, 16);
float amplitude = 1.0;
float frequency = 1.0;
float value = 0.0;
for (int i = 0; i < octaves; i++)
{
value += amplitude * cultmath_snoise(p * frequency);
frequency *= lacunarity;
amplitude *= gain;
}
return value;
}
float cultmath_fbm(float3 value) { return cultmath_fbm(value, 4, 2.0, 0.5); }
// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad // Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad
// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. // comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped.
float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain) float4 cultmath_ridged_grad(float3 p, int octaves, float lacunarity, float gain)

View file

@ -1,8 +1,14 @@
# Changelog # Changelog
All notable changes to `cultmesh-browser` are recorded here, following All notable changes to `@gamecult/cultmesh-browser` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased
### Breaking
- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`.
## [0.4.0] ## [0.4.0]
### Added ### Added

View file

@ -1,6 +1,6 @@
# CultMesh Browser # CultMesh Browser
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one `@gamecult/cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
document leases across route replacement, and correlates typed CultNet document leases across route replacement, and correlates typed CultNet
operations with provider receipts. operations with provider receipts.
@ -95,7 +95,7 @@ WebCrypto `crypto.subtle`; without WebCrypto it fails with
endpoint CultMesh resolved is the only host the reader trusts. endpoint CultMesh resolved is the only host the reader trusts.
```ts ```ts
import { fetchVerifiedContent } from "cultmesh-browser"; import { fetchVerifiedContent } from "@gamecult/cultmesh-browser";
const bytes = await fetchVerifiedContent(endpoint, manifest, { const bytes = await fetchVerifiedContent(endpoint, manifest, {
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),

View file

@ -1,5 +1,5 @@
{ {
"name": "cultmesh-browser", "name": "@gamecult/cultmesh-browser",
"version": "0.4.0", "version": "0.4.0",
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
"type": "module", "type": "module",
@ -41,12 +41,12 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0" "@gamecult/cultnet-ts": "^0.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"cultmesh-ts": "^0.2.0", "@gamecult/cultmesh-ts": "^0.2.0",
"typescript": "^5.9.0" "typescript": "^5.9.0"
} }
} }

View file

@ -3,8 +3,8 @@
// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence). // The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence).
// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here. // Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here.
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
import { isLoopbackEndpoint } from "cultnet-ts/authority"; import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority";
export type CultMeshContentErrorCode = export type CultMeshContentErrorCode =
| "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network" | "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network"

View file

@ -1,6 +1,6 @@
import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document"; import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document";
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts"; import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
encodeCultNetMessageForWire, encodeCultNetMessageForWire,
parseCultNetMessage, parseCultNetMessage,
@ -16,7 +16,7 @@ import {
type CultMeshVerseCatalogResponseMessage, type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionAcceptedMessage, type CultMeshSessionAcceptedMessage,
type CultMeshSessionOpenMessage, type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts"; } from "@gamecult/cultnet-ts/contracts";
import { import {
base64ToBytes, base64ToBytes,
bytesToBase64, bytesToBase64,
@ -31,7 +31,7 @@ import {
type CultMeshAuthorityTrustMode, type CultMeshAuthorityTrustMode,
type CultMeshAuthorityTrustPolicy, type CultMeshAuthorityTrustPolicy,
type CultMeshP256PublicKey, type CultMeshP256PublicKey,
} from "cultnet-ts/authority"; } from "@gamecult/cultnet-ts/authority";
export * from "./content.js"; export * from "./content.js";
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;

View file

@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { build } from "esbuild"; import { build } from "esbuild";
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js"; import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js";

View file

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
encodeCultNetMessageForWire, encodeCultNetMessageForWire,
parseCultNetMessage, parseCultNetMessage,
@ -12,7 +12,7 @@ import {
type CultNetRawDocumentRecord, type CultNetRawDocumentRecord,
type CultMeshVerseCatalogResponseMessage, type CultMeshVerseCatalogResponseMessage,
type CultMeshSessionOpenMessage, type CultMeshSessionOpenMessage,
} from "cultnet-ts/contracts"; } from "@gamecult/cultnet-ts/contracts";
import { import {
CultMeshBrowserClient, CultMeshBrowserClient,

View file

@ -5,8 +5,8 @@ import { readFileSync } from "node:fs";
import { createServer } from "node:https"; import { createServer } from "node:https";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { createInterface } from "node:readline"; import { createInterface } from "node:readline";
import { chunkRecordKey } from "cultnet-ts/content"; import { chunkRecordKey } from "@gamecult/cultnet-ts/content";
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content"; import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content";
const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string]; const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string];
const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); };

View file

@ -3,7 +3,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { decodeManifest } from "cultnet-ts/content"; import { decodeManifest } from "@gamecult/cultnet-ts/content";
import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js"; import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js";
const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined]; const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined];

View file

@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content"; import { EMPTY_SHA256, encodeManifest, packContent } from "@gamecult/cultnet-ts/content";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop

View file

@ -2,8 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
import { decodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts"; import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts";
import { decodeCultNetPayload } from "../src/index.js"; import { decodeCultNetPayload } from "../src/index.js";

View file

@ -808,6 +808,7 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
val profile: CultNetTransportProfile val profile: CultNetTransportProfile
val stats: CultNetTransportStats val stats: CultNetTransportStats
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchemaMessage(message: CultNetMessage) fun sendSchemaMessage(message: CultNetMessage)
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage? fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
@ -3167,13 +3168,16 @@ class CultNetRudpSocketTransportConnection(
return connected return connected
} }
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: ByteArray) { fun send(channelId: String, payload: ByteArray) {
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) } session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
framesSent += 1 framesSent += 1
} }
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8)) fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
fun sendSchema(payload: ByteArray) = send("schema", payload) fun sendSchema(payload: ByteArray) = send("schema", payload)
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8)) fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))

View file

@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the
local TCP/RUDP server, and a package daemon. local TCP/RUDP server, and a package daemon.
It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and
`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream `@gamecult/cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
negotiation, and simulation fact contracts are defined in negotiation, and simulation fact contracts are defined in
`cultnet_py.cultmesh_contracts` and re-exported here. `cultnet_py.cultmesh_contracts` and re-exported here.
@ -122,6 +122,33 @@ server = CultMesh.serve_node(
) )
``` ```
## Peer writes
**Breaking (0.x):** a served node refuses every peer `document_put_raw` and `document_delete` unless a write
authority grants it, mirroring C# `ICultNetRemoteWriteAuthority`. Before this, any connected peer could overwrite
or delete the node's documents. The refusal is a typed `cultnet.error.v0` with code `write_not_authorized` and the
record key in `details`; an authority that raises, or returns no decision, refuses too.
Known residual: ungranted peers can still distinguish registered from unregistered schemas by timing (the node resolves the
schema before the gate so the authority sees the canonical id); the refusal itself is identical.
```python
from cultmesh_py import CultMeshGrantAllWrites, CultMeshRemoteWriteDecision, CultMeshRemoteWriteRequest
class OwnKeysOnly:
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
if request.record_key.startswith("input:python:"):
return CultMeshRemoteWriteDecision.grant()
return CultMeshRemoteWriteDecision.refuse("not your key")
server = CultMesh.serve_node(node, write_authority=OwnKeysOnly())
```
The request carries the peer (`endpoint`, `transport`), the operation, the canonical schema id the node
resolved (never the alias the peer sent), the record key and the shard id and epoch as sent. `session.serve(...)`
takes the same `write_authority`. A host that already authenticates its peers, or a test rig, opts in to
`CultMeshGrantAllWrites()`; the daemon's equivalent is `--allow-peer-writes`. Without it the daemon is read-only to peers.
The same local server can be launched as a package daemon when another runtime The same local server can be launched as a package daemon when another runtime
or operator process needs a CultNet endpoint without embedding Python glue: or operator process needs a CultNet endpoint without embedding Python glue:
@ -228,6 +255,17 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted
unsubscribe() unsubscribe()
``` ```
## Authority route verification
`cultmesh_py.authority` ports the C# reference
(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate`
judges an Odin-signed ECDSA P-256 route (channel protection, trusted root,
validity window, IEEE P1363 signature over the canonical bytes) and
`verify_session_proof` checks the provider nonce proof. Refusals raise
`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`.
`tests/test_authority_route_vectors.py` judges
`contracts/cultmesh/authority-route-vectors*.json`.
## Verify ## Verify
`cultmesh_py.verify` checks the public exports and `py.typed` markers of all `cultmesh_py.verify` checks the public exports and `py.typed` markers of all

View file

@ -16,7 +16,7 @@ classifiers = [
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
] ]
dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"] dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"]
[project.scripts] [project.scripts]
cultmesh-py-daemon = "cultmesh_py.daemon:main" cultmesh-py-daemon = "cultmesh_py.daemon:main"
cultmesh-py-verify = "cultmesh_py.verify:main" cultmesh-py-verify = "cultmesh_py.verify:main"

View file

@ -39,6 +39,14 @@ from .simulation import (
CultMeshSimulationFactCommitter, CultMeshSimulationFactCommitter,
simulation_fact_document, simulation_fact_document,
) )
from .write_authority import (
CultMeshGrantAllWrites,
CultMeshRemoteWriteAuthority,
CultMeshRemoteWriteDecision,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
)
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_REQUEST,
PEER_EXCHANGE_RESPONSE, PEER_EXCHANGE_RESPONSE,
@ -89,6 +97,12 @@ __all__ = [
"CultMeshSimulationFact", "CultMeshSimulationFact",
"CultMeshSimulationFactCommit", "CultMeshSimulationFactCommit",
"CultMeshSimulationFactCommitter", "CultMeshSimulationFactCommitter",
"CultMeshGrantAllWrites",
"CultMeshRemoteWriteAuthority",
"CultMeshRemoteWriteDecision",
"CultMeshRemoteWriteOperation",
"CultMeshRemoteWriteRequest",
"CultMeshServerPeer",
"CultMeshAuthorityLease", "CultMeshAuthorityLease",
"CultMeshAuthorityLeaseCatalog", "CultMeshAuthorityLeaseCatalog",
"CultMeshHmacAuthorityLeaseVerifier", "CultMeshHmacAuthorityLeaseVerifier",

View file

@ -0,0 +1,338 @@
"""CultMesh authority route verification.
Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the
route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the
same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`:
unsigned, channel protection, root lookup, validity window, signature.
Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
"""
from __future__ import annotations
import base64
import binascii
import re
import struct
from dataclasses import dataclass
from typing import Iterable, Mapping, Sequence
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1"
SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1"
AUTHENTICATED_REMOTE = "authenticated-remote"
LOCAL_DEVELOPMENT = "local-development"
# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085.
# `str.strip()` is a different set (it also strips U+001C-U+001F).
_CSHARP_WHITESPACE = (
"\u0009\u000a\u000b\u000c\u000d \u0085  "
"           "
"

   "
)
def trim_csharp(value: str) -> str:
return value.strip(_CSHARP_WHITESPACE)
def is_blank_csharp(value: str | None) -> bool:
return value is None or trim_csharp(value) == ""
def _require(value: str | None, name: str) -> str:
if is_blank_csharp(value):
raise ValueError(f"Value must be non-empty. (Parameter '{name}')")
return trim_csharp(value) # type: ignore[arg-type]
class AuthorityRouteRefusal(Exception):
"""A route or proof the consumer's trust policy refuses.
Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with
reason `Authentication`: `reason` is always "authentication".
"""
reason = "authentication"
def __init__(self, message: str, endpoint: str = "") -> None:
super().__init__(message)
self.message = message
self.endpoint = endpoint
def _b64_strict(value: str) -> bytes:
# `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored.
stripped = re.sub(r"[ \t\r\n]", "", value)
return base64.b64decode(stripped, validate=True)
@dataclass(frozen=True)
class P256PublicKey:
key_id: str
x: str
y: str
def __post_init__(self) -> None:
object.__setattr__(self, "key_id", _require(self.key_id, "keyId"))
for name in ("x", "y"):
value = _require(getattr(self, name), name)
try:
_b64_strict(value)
except (binascii.Error, ValueError) as error:
raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error
object.__setattr__(self, name, value)
def verify(self, payload: bytes, signature_base64: str) -> bool:
"""IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation."""
try:
x, y = _b64_strict(self.x), _b64_strict(self.y)
signature = _b64_strict(signature_base64)
if len(x) != 32 or len(y) != 32 or len(signature) != 64:
return False
key = ec.EllipticCurvePublicNumbers(
int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1()
).public_key()
der = encode_dss_signature(
int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
)
key.verify(der, payload, ec.ECDSA(hashes.SHA256()))
return True
except (InvalidSignature, ValueError, binascii.Error):
return False
@dataclass(frozen=True)
class RouteCertificate:
provider_key: P256PublicKey
odin_key_id: str
issued_at_unix_milliseconds: int
expires_at_unix_milliseconds: int
signature: str = ""
def __post_init__(self) -> None:
if is_blank_csharp(self.odin_key_id):
raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')")
object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id))
if self.issued_at_unix_milliseconds < 0:
raise ValueError("issuedAtUnixMilliseconds must not be negative.")
if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds:
raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.")
object.__setattr__(self, "signature", trim_csharp(self.signature or ""))
@dataclass(frozen=True)
class AuthorityRoute:
"""`CultMeshAuthorityRoute`: construction cleans every field as the C# does."""
authority_runtime_id: str
endpoint: str
protocol_ids: Sequence[str] = ()
priority: int = 0
generation: str | None = None
certificate: RouteCertificate | None = None
def __post_init__(self) -> None:
runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId")
endpoint = _require(self.endpoint, "endpoint")
if self.priority < 0:
raise ValueError("priority must not be negative.")
object.__setattr__(self, "authority_runtime_id", runtime_id)
object.__setattr__(self, "endpoint", endpoint)
object.__setattr__(
self,
"protocol_ids",
tuple(
sorted(
{trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)},
key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order
)
),
)
object.__setattr__(
self,
"generation",
f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type]
)
@dataclass(frozen=True)
class SessionOpen:
"""The fields of `CultMeshSessionOpenMessage` the session proof covers."""
client_nonce: str | None = None
message_id: str | None = None
source_runtime_id: str | None = None
verse_id: str | None = None
authority_runtime_id: str | None = None
protocol_id: str | None = None
route_generation: str | None = None
@dataclass(frozen=True)
class SessionAccepted:
client_nonce: str | None
provider_key_id: str | None
provider_signature: str | None
@dataclass(frozen=True)
class VerifiedRoute:
route: AuthorityRoute
provider_key: P256PublicKey | None
is_local_development: bool
def canonical_fields(*values: str | None) -> bytes:
out = bytearray()
for value in values:
encoded = (value or "").encode("utf-8")
out += struct.pack(">I", len(encoded)) + encoded
return bytes(out)
def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes:
certificate = route.certificate
if certificate is None:
raise ValueError("Route certificate is required.")
return canonical_fields(
ROUTE_DOMAIN,
verse_id,
route.authority_runtime_id,
route.endpoint,
"\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point
str(route.priority),
route.generation,
certificate.provider_key.key_id,
certificate.provider_key.x,
certificate.provider_key.y,
certificate.odin_key_id,
str(certificate.issued_at_unix_milliseconds),
str(certificate.expires_at_unix_milliseconds),
)
def canonical_session(request: SessionOpen, endpoint: str) -> bytes:
return canonical_fields(
SESSION_DOMAIN,
request.client_nonce,
request.message_id,
request.source_runtime_id,
request.verse_id,
request.authority_runtime_id,
request.protocol_id,
endpoint,
request.route_generation,
)
# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call
# a refused endpoint loopback or protected. Same shape rule as the TypeScript port.
_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)")
def _split_endpoint(value: str) -> tuple[str, str] | None:
"""Return (scheme, lowercase host) or None where System.Uri would refuse."""
raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value)
if "\\" in raw:
return None
shape = _AUTHORITY.match(raw)
if not shape:
return None
authority = shape.group(1)
after_user = authority[authority.rfind("@") + 1 :]
if after_user.startswith("["):
host = after_user[: after_user.find("]") + 1]
else:
host = after_user.split(":")[0]
if not host or re.search(r"[^!-~]", host) or host.endswith("."):
return None
return raw.split("://", 1)[0].lower(), host.strip("[]").lower()
def is_loopback_endpoint(endpoint: str) -> bool:
parts = _split_endpoint(endpoint)
if parts is None:
return False
host = parts[1]
return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool(
re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host)
)
def is_protected_endpoint(endpoint: str) -> bool:
parts = _split_endpoint(endpoint)
return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0])
class AuthorityTrustPolicy:
"""Consumer-owned policy. Fails closed unless a route is valid under it."""
def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None:
if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT):
raise ValueError(f"Unknown trust mode '{mode}'.")
self.mode = mode
self._roots: dict[str, P256PublicKey] = {}
for root in odin_roots:
if root.key_id in self._roots:
raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.")
self._roots[root.key_id] = root
def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None:
self.verify(verse_id, route, now_unix_milliseconds)
def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute:
local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint)
def refuse(message: str) -> AuthorityRouteRefusal:
return AuthorityRouteRefusal(message, route.endpoint)
certificate = route.certificate
if certificate is None or is_blank_csharp(certificate.signature):
if local:
return VerifiedRoute(route, None, True)
raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.")
if not is_protected_endpoint(route.endpoint) and not local:
raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")
root = self._roots.get(certificate.odin_key_id)
if root is None:
raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.")
if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds):
raise refuse("The Odin route certificate is not currently valid.")
try:
signature = _b64_strict(certificate.signature)
except (binascii.Error, ValueError):
raise refuse("The Odin route signature is not base64.") from None
if len(signature) != 64:
raise refuse("The Odin route signature is not IEEE P1363 P-256.")
if not root.verify(canonical_route(verse_id, route), certificate.signature):
raise refuse("The Odin route certificate signature is invalid.")
return VerifiedRoute(route, certificate.provider_key, False)
def verify_session_proof(
request: SessionOpen,
response: SessionAccepted,
verse_id: str,
route: AuthorityRoute,
trust: AuthorityTrustPolicy,
now_unix_milliseconds: int,
) -> bool:
"""Verifies the Odin route certificate (raising) and the provider nonce proof."""
verified = trust.verify(verse_id, route, now_unix_milliseconds)
if verified.is_local_development:
return True
key = verified.provider_key
if (
key is None
or response.provider_key_id != key.key_id
or response.client_nonce != request.client_nonce
or is_blank_csharp(response.provider_signature)
):
return False
return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type]

View file

@ -13,6 +13,7 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser
from .facade import CultMesh from .facade import CultMesh
from .server import CultMeshLocalServer from .server import CultMeshLocalServer
from .write_authority import CultMeshGrantAllWrites
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
CultMeshPeerCard, CultMeshPeerCard,
CultMeshPeerCatalog, CultMeshPeerCatalog,
@ -53,6 +54,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5) parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5)
parser.add_argument("--verse-id") parser.add_argument("--verse-id")
parser.add_argument("--verse-display-name") parser.add_argument("--verse-display-name")
parser.add_argument("--allow-peer-writes", action="store_true", help="grant every connected peer put and delete; without it peer writes are refused")
parser.add_argument("--role", action="append", dest="roles") parser.add_argument("--role", action="append", dest="roles")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@ -117,6 +119,7 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C
display_name=args.display_name, display_name=args.display_name,
max_snapshot_documents=args.max_snapshot_documents, max_snapshot_documents=args.max_snapshot_documents,
max_snapshot_bytes=args.max_snapshot_bytes, max_snapshot_bytes=args.max_snapshot_bytes,
write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None,
) )
except ValueError as error: except ValueError as error:
# The server owns its option rules; a limit it refuses is a usage error, not a crash. # The server owns its option rules; a limit it refuses is a usage error, not a crash.

View file

@ -35,6 +35,7 @@ from .node import (
from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer
from .session import CultMeshGameSession, CultMeshGameSessionOptions from .session import CultMeshGameSession, CultMeshGameSessionOptions
from .simulation import CultMeshSimulationFactCommitter from .simulation import CultMeshSimulationFactCommitter
from .write_authority import CultMeshRemoteWriteAuthority
from cultnet_py.cultmesh_contracts import ( from cultnet_py.cultmesh_contracts import (
AuthorityLeaseVerifier, AuthorityLeaseVerifier,
CultMeshAuthorityLeaseCatalog, CultMeshAuthorityLeaseCatalog,
@ -400,6 +401,7 @@ class CultMesh:
verse_catalog: CultMeshVerseCatalog | None = None, verse_catalog: CultMeshVerseCatalog | None = None,
peer_catalog: CultMeshPeerCatalog | None = None, peer_catalog: CultMeshPeerCatalog | None = None,
observation_hub: CultNetSimulationObservationHub | None = None, observation_hub: CultNetSimulationObservationHub | None = None,
write_authority: CultMeshRemoteWriteAuthority | None = None,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 0, port: int = 0,
display_name: str | None = None, display_name: str | None = None,
@ -416,6 +418,7 @@ class CultMesh:
verse_catalog=verse_catalog or CultMeshVerseCatalog(), verse_catalog=verse_catalog or CultMeshVerseCatalog(),
peer_catalog=peer_catalog or CultMeshPeerCatalog(), peer_catalog=peer_catalog or CultMeshPeerCatalog(),
observation_hub=observation_hub, observation_hub=observation_hub,
write_authority=write_authority,
host=host, host=host,
port=port, port=port,
display_name=display_name, display_name=display_name,

View file

@ -650,6 +650,29 @@ class CultMeshDatabase:
self._append_shard_log_delete(message) self._append_shard_log_delete(message)
return message return message
def raw_put_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
"""The canonical schema id and record key a raw put would write, or None when it would not apply."""
document_record = message.get("document")
# Only a document of the wire shape is resolved, so an ungranted peer's payload is never
# converted or allocated here: a malformed one is the store's refusal, after the authority.
if (
not isinstance(document_record, dict)
or not isinstance(document_record.get("recordKey"), str)
or not isinstance(document_record.get("payload"), (bytes, bytearray, memoryview))
):
return None
try:
prepared = self._raw_put_envelope(message)
except Exception: # noqa: BLE001 - any other malformed shape
return None
return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key)
def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
"""The canonical schema id and record key a raw delete names, or None when its schema is not registered."""
document = self._document_for_schema(str(message.get("schemaId") or ""))
record_key = str(message.get("recordKey") or "")
return None if document is None or not record_key else (document.catalog_entry().schema_id, record_key)
def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None: def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None:
prepared = self._raw_put_envelope(message) prepared = self._raw_put_envelope(message)
if prepared is None: if prepared is None:

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import errno
import logging import logging
import socket import socket
import threading import threading
@ -10,6 +11,9 @@ from typing import Any
import msgpack import msgpack
from cultnet_py import ( from cultnet_py import (
error_message,
message_invalid_error,
unsupported_schema_version_error,
CultNetRawSnapshotResponse, CultNetRawSnapshotResponse,
CultNetSimulationObservationHub, CultNetSimulationObservationHub,
CultNetRudpPacket, CultNetRudpPacket,
@ -27,6 +31,12 @@ from cultnet_py import (
) )
from .node import CultMeshNode from .node import CultMeshNode
from .write_authority import (
CultMeshRemoteWriteAuthority,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
)
# The shortest message id this server answers a snapshot request under. It takes a # The shortest message id this server answers a snapshot request under. It takes a
# request's messageId as given, an empty one included, so no response it sends is smaller. # request's messageId as given, an empty one included, so no response it sends is smaller.
@ -50,6 +60,17 @@ from cultnet_py.cultmesh_contracts import (
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
# How many consecutive refused frames of one kind a RUDP session may send before it is ended: undecodable
# frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any decoded
# frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
# frame that reaches this number is answered too, then the session ends. The same number is the cap in every
# runtime.
MAX_CONSECUTIVE_REFUSED_FRAMES = 8
# How long a session whose capped frame was answered waits for the peer to acknowledge the answer before it ends anyway.
_RUDP_CAPPED_ANSWER_GRACE_MS = 2000
_EPHEMERAL_PAIR_ATTEMPTS = 64
class _ReplyUnqueueable(Exception): class _ReplyUnqueueable(Exception):
"""A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is """A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is
@ -79,6 +100,10 @@ class _DatabaseSubscription:
class _RudpPeerConnection: class _RudpPeerConnection:
session: CultNetRudpSession session: CultNetRudpSession
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict) subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
undecodable_frames_in_a_row: int = 0
refused_deletes_in_a_row: int = 0
# Set once the peer's capped frame has been answered: the session ends when the answers are acknowledged.
ending_deadline_ms: int | None = None
@dataclass @dataclass
@ -87,6 +112,9 @@ class CultMeshLocalServer:
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog) verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog) peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
observation_hub: CultNetSimulationObservationHub | None = None observation_hub: CultNetSimulationObservationHub | None = None
# Asked once per peer put and delete. None refuses every one of them (write_not_authorized); a node's own
# writes never come through here.
write_authority: CultMeshRemoteWriteAuthority | None = None
host: str = "127.0.0.1" host: str = "127.0.0.1"
port: int = 0 port: int = 0
display_name: str | None = None display_name: str | None = None
@ -157,26 +185,49 @@ class CultMeshLocalServer:
def start(self) -> "CultMeshLocalServer": def start(self) -> "CultMeshLocalServer":
if self._socket is not None: if self._socket is not None:
return self return self
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server, rudp_socket = self._bind_sockets()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((self.host, self.port))
server.listen()
server.settimeout(0.2) server.settimeout(0.2)
self._socket = server self._socket = server
self.port = int(server.getsockname()[1]) self.port = int(server.getsockname()[1])
self._stop.clear() self._stop.clear()
self._thread = threading.Thread(target=self._accept_loop, daemon=True) self._thread = threading.Thread(target=self._accept_loop, daemon=True)
self._thread.start() self._thread.start()
if self.enable_rudp: if rudp_socket is not None:
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
rudp_socket.bind((self.host, self.port))
rudp_socket.settimeout(0.02) rudp_socket.settimeout(0.02)
self._rudp_socket = rudp_socket self._rudp_socket = rudp_socket
self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True) self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True)
self._rudp_thread.start() self._rudp_thread.start()
return self return self
def _bind_sockets(self) -> tuple[socket.socket, socket.socket | None]:
"""The listening TCP socket and, when RUDP is on, the UDP socket on the same port number.
An explicit port is bound as asked and a failure is the caller's. Port 0 asks for any free port, and the
kernel picks the TCP one without knowing whether another process holds that number for UDP, so a
UDP collision retries with a fresh TCP port instead of failing the start. Both sockets are bound
before any thread starts, so a failed start leaves nothing running or open.
"""
attempts = _EPHEMERAL_PAIR_ATTEMPTS if self.port == 0 and self.enable_rudp else 1
for attempt in range(attempts):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
rudp_socket = None
try:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((self.host, self.port))
server.listen()
if self.enable_rudp:
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
rudp_socket.bind((self.host, int(server.getsockname()[1])))
return server, rudp_socket
except OSError as error:
server.close()
if rudp_socket is not None:
rudp_socket.close()
if error.errno != errno.EADDRINUSE or attempt == attempts - 1:
raise
raise AssertionError("unreachable")
def stop(self) -> None: def stop(self) -> None:
self._stop.set() self._stop.set()
if self._rudp_socket is not None: if self._rudp_socket is not None:
@ -229,12 +280,7 @@ class CultMeshLocalServer:
if schema_version == "cultnet.simulation_observation.v0": if schema_version == "cultnet.simulation_observation.v0":
candidates = self._candidate_responses(message) candidates = self._candidate_responses(message)
return candidates[0] if candidates else None return candidates[0] if candidates else None
return self._error_response( return unsupported_schema_version_error(schema_version).to_wire()
f"Unsupported CultNet message schema: {schema_version!r}.",
message_id=str(message.get("messageId") or ""),
code="unsupported_schema_version",
details={"schemaVersion": schema_version},
)
def _accept_loop(self) -> None: def _accept_loop(self) -> None:
while not self._stop.is_set(): while not self._stop.is_set():
@ -242,14 +288,15 @@ class CultMeshLocalServer:
if server is None: if server is None:
return return
try: try:
client, _ = server.accept() client, address = server.accept()
except TimeoutError: except TimeoutError:
continue continue
except OSError: except OSError:
return return
threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start() peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp")
threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start()
def _handle_connection(self, client: socket.socket) -> None: def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None:
with client: with client:
stream = client.makefile("rwb") stream = client.makefile("rwb")
transport = TcpFramedTransportConnection( transport = TcpFramedTransportConnection(
@ -271,7 +318,7 @@ class CultMeshLocalServer:
if not isinstance(message, dict): if not isinstance(message, dict):
continue continue
try: try:
responses = self._handle_connection_message(message, subscriptions) responses = self._handle_connection_message(message, subscriptions, peer=peer)
except Exception as error: # noqa: BLE001 - one message never ends the connection except Exception as error: # noqa: BLE001 - one message never ends the connection
# Whatever a peer's message makes the handling raise is that peer's failure: # Whatever a peer's message makes the handling raise is that peer's failure:
# it is refused with fixed text and the connection serves on. The log names # it is refused with fixed text and the connection serves on. The log names
@ -313,6 +360,7 @@ class CultMeshLocalServer:
if packet.connection_id != self.rudp_connection_id: if packet.connection_id != self.rudp_connection_id:
continue continue
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms() now_ms = _now_ms()
peer = peers.get(remote_addr) peer = peers.get(remote_addr)
if packet.packet_type == CultNetRudpPacketType.CONNECT: if packet.packet_type == CultNetRudpPacketType.CONNECT:
@ -361,13 +409,31 @@ class CultMeshLocalServer:
for delivered in result.delivered: for delivered in result.delivered:
if delivered.channel_id != "schema": if delivered.channel_id != "schema":
continue continue
if peer.ending_deadline_ms is not None:
# The capped frame was answered; nothing after it is served while the answer is delivered.
break
try: try:
message = msgpack.unpackb(delivered.payload, raw=False) message = msgpack.unpackb(delivered.payload, raw=False)
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError): except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
continue message = None
if not isinstance(message, dict): if isinstance(message, dict):
continue peer.undecodable_frames_in_a_row = 0
responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True) responses = self._handle_connection_message(
message,
peer.subscriptions,
over_rudp=True,
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
)
refused_delete = message.get("schemaVersion") == "cultnet.document_delete.v0" and any(
response.get("code") == "write_not_authorized" for response in responses
)
peer.refused_deletes_in_a_row = peer.refused_deletes_in_a_row + 1 if refused_delete else 0
refused, run = refused_delete, peer.refused_deletes_in_a_row
else:
# A frame that is no CultNet message is answered, not dropped, and the session kept.
peer.undecodable_frames_in_a_row += 1
refused, run = True, peer.undecodable_frames_in_a_row
responses = [self._message_invalid({}, "message", "CultMesh RUDP server could not read the message.")]
for response in responses: for response in responses:
if unsendable is None: if unsendable is None:
unsendable = self._send_rudp_schema_frame( unsendable = self._send_rudp_schema_frame(
@ -377,6 +443,11 @@ class CultMeshLocalServer:
msgpack.packb(response, use_bin_type=True), msgpack.packb(response, use_bin_type=True),
message_id=str(response.get("messageId") or ""), message_id=str(response.get("messageId") or ""),
) )
if refused and run >= MAX_CONSECUTIVE_REFUSED_FRAMES:
# The capped frame is answered like any other, reliably. The session ends once the peer
# has acknowledged every answer (or the grace runs out): see _end_answered_peers.
peer.ending_deadline_ms = now_ms + _RUDP_CAPPED_ANSWER_GRACE_MS
break
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
# Whatever a peer's message makes the handling raise is that peer's failure. # Whatever a peer's message makes the handling raise is that peer's failure.
# The session recorded the frame's sequence, so it cannot be kept: a retransmit # The session recorded the frame's sequence, so it cannot be kept: a retransmit
@ -407,11 +478,26 @@ class CultMeshLocalServer:
if unsendable is not None: if unsendable is not None:
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable) self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
def _end_answered_peers(
self,
rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None:
"""Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
answer is resent first), or when the grace has run out."""
now_ms = _now_ms()
for remote_addr, peer in list(peers.items()):
if peer.ending_deadline_ms is None:
continue
if peer.session.outstanding_reliable_packet_count == 0 or now_ms >= peer.ending_deadline_ms:
self._end_refused_peer(rudp_socket, peers, remote_addr)
def _poll_rudp_resends( def _poll_rudp_resends(
self, self,
rudp_socket: socket.socket, rudp_socket: socket.socket,
peers: dict[tuple[str, int], _RudpPeerConnection], peers: dict[tuple[str, int], _RudpPeerConnection],
) -> None: ) -> None:
self._end_answered_peers(rudp_socket, peers)
now_ms = _now_ms() now_ms = _now_ms()
for remote_addr, peer in list(peers.items()): for remote_addr, peer in list(peers.items()):
for packet in peer.session.due_resends(now_ms): for packet in peer.session.due_resends(now_ms):
@ -523,6 +609,7 @@ class CultMeshLocalServer:
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*, *,
over_rudp: bool = False, over_rudp: bool = False,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
schema_version = message.get("schemaVersion") schema_version = message.get("schemaVersion")
if schema_version == "cultnet.database_subscribe.v0": if schema_version == "cultnet.database_subscribe.v0":
@ -546,9 +633,9 @@ class CultMeshLocalServer:
subscriptions.pop(str(message.get("subscriptionId") or ""), None) subscriptions.pop(str(message.get("subscriptionId") or ""), None)
return [] return []
if schema_version == "cultnet.document_put_raw.v0": if schema_version == "cultnet.document_put_raw.v0":
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp) return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp, peer=peer)
if schema_version == "cultnet.document_delete.v0": if schema_version == "cultnet.document_delete.v0":
return self._handle_raw_delete(message, subscriptions) return self._handle_raw_delete(message, subscriptions, peer=peer)
if schema_version == "cultnet.simulation_observation.v0": if schema_version == "cultnet.simulation_observation.v0":
return self._candidate_responses(message) return self._candidate_responses(message)
response = self.handle_message(message) response = self.handle_message(message)
@ -560,14 +647,27 @@ class CultMeshLocalServer:
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*, *,
over_rudp: bool = False, over_rudp: bool = False,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
document_record = message.get("document") document_record = message.get("document")
if not isinstance(document_record, dict): if not isinstance(document_record, dict):
return [self._error_response( return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")]
"Raw put messages must contain a document map.", malformed_shard = self._malformed_shard_fields(message)
message_id=str(message.get("messageId") or ""), if malformed_shard is not None:
code="malformed_document_put", return [malformed_shard]
)] # Refusal precedes every other judgment of the put. The node resolves the schema first, because the
# authority must be asked about the canonical schema id (a payload-inferred alias included), as C#
# does; only a payload of the wire shape is resolved, so nothing of an ungranted peer's is
# allocated. A put the node cannot resolve is asked about under the ids as sent. Residual: the
# refusal is the same either way, but an ungranted peer can still tell registered from unregistered
# schemas by timing.
target = self.node.database.raw_put_target(message) or (
str(document_record.get("schemaId") or ""),
str(document_record.get("recordKey") or ""),
)
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.PUT, target, message)
if refusal is not None:
return [refusal]
unservable = self._unservable_put(message, over_rudp=over_rudp) unservable = self._unservable_put(message, over_rudp=over_rudp)
if unservable is not None: if unservable is not None:
return [unservable] return [unservable]
@ -579,11 +679,7 @@ class CultMeshLocalServer:
# raises. It is refused to the peer that sent it; no thread and no other peer is # raises. It is refused to the peer that sent it; no thread and no other peer is
# affected. Neither the refusal nor the log quotes the error, which can quote the put. # affected. Neither the refusal nor the log quotes the error, which can quote the put.
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__) _LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
return [self._error_response( return [self._message_invalid(message, "document", "Raw put document could not be stored.")]
"Raw put document could not be stored.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)]
if change is None: if change is None:
return [self._error_response( return [self._error_response(
"Raw put message did not apply to a registered document.", "Raw put message did not apply to a registered document.",
@ -602,15 +698,22 @@ class CultMeshLocalServer:
self, self,
message: dict[str, Any], message: dict[str, Any],
subscriptions: dict[str, _DatabaseSubscription], subscriptions: dict[str, _DatabaseSubscription],
*,
peer: CultMeshServerPeer | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
schema_id = str(message.get("schemaId") or "") schema_id = str(message.get("schemaId") or "")
record_key = str(message.get("recordKey") or "") record_key = str(message.get("recordKey") or "")
if not schema_id or not record_key: if not schema_id or not record_key:
return [self._error_response( return [self._message_invalid(
"Raw delete messages must contain schemaId and recordKey.", message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_delete",
)] )]
malformed_shard = self._malformed_shard_fields(message)
if malformed_shard is not None:
return [malformed_shard]
target = self.node.database.raw_delete_target(message) or (schema_id, record_key)
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.DELETE, target, message)
if refusal is not None:
return [refusal]
change = self.node.database.apply_raw_delete_message(message) change = self.node.database.apply_raw_delete_message(message)
if change is None: if change is None:
return [self._error_response( return [self._error_response(
@ -620,6 +723,70 @@ class CultMeshLocalServer:
)] )]
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id) return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
def _message_invalid(self, message: dict[str, Any], field: str, reason: str) -> dict[str, Any]:
"""The typed `message_invalid` refusal naming the request field at fault, under the request's messageId."""
response = message_invalid_error(field, reason).to_wire()
response["messageId"] = str(message.get("messageId") or "")
return response
def _malformed_shard_fields(self, message: dict[str, Any]) -> dict[str, Any] | None:
"""The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is
not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can
read, and the store never files a write under a shard the authority did not see."""
shard_id = message.get("shardId")
shard_epoch = message.get("shardEpoch")
if (shard_id is None or isinstance(shard_id, str)) and (
shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool))
):
return None
return self._message_invalid(
message,
"shardId" if not (shard_id is None or isinstance(shard_id, str)) else "shardEpoch",
"shardId must be a string and shardEpoch an integer.",
)
def _refuse_unauthorized_write(
self,
peer: CultMeshServerPeer | None,
operation: CultMeshRemoteWriteOperation,
target: tuple[str, str],
message: dict[str, Any],
) -> dict[str, Any] | None:
"""None when the write may proceed, else the typed refusal for the sending peer.
No authority configured refuses; an authority that raises refuses too (fail closed) and is logged.
"""
schema_id, record_key = target
reason: str | None
authority = self.write_authority
if authority is None:
reason = "no remote write authority is configured"
else:
request = CultMeshRemoteWriteRequest(
peer=peer or CultMeshServerPeer("", "unknown"),
operation=operation,
schema_id=schema_id,
record_key=record_key,
shard_id=message.get("shardId"),
shard_epoch=message.get("shardEpoch"),
)
try:
decision = authority.decide(request)
except Exception as error: # noqa: BLE001 - a failing authority refuses
_LOGGER.error("CultMesh remote write authority failed (%s); the write was refused.", type(error).__name__)
decision = None
reason = "the write authority failed"
else:
reason = getattr(decision, "reason", None)
if decision is not None and getattr(decision, "is_granted", False) is True:
return None
_LOGGER.warning("CultMesh %s of %s refused: write not authorized.", operation.value, schema_id)
return error_message(
f"write_not_authorized: {reason}" if reason and reason.strip() else "write_not_authorized",
code="write_not_authorized",
details={"field": "recordKey", "value": record_key},
).to_wire()
def _database_change_notifications( def _database_change_notifications(
self, self,
message: dict[str, Any], message: dict[str, Any],
@ -807,11 +974,7 @@ class CultMeshLocalServer:
and isinstance(document_record.get("recordKey"), str) and isinstance(document_record.get("recordKey"), str)
and isinstance(document_record.get("payload"), (bytes, bytearray)) and isinstance(document_record.get("payload"), (bytes, bytearray))
): ):
return self._error_response( return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
"Raw put documents must carry a string recordKey and a binary payload.",
message_id=str(message.get("messageId") or ""),
code="malformed_document_put",
)
if self.max_snapshot_bytes is None and not over_rudp: if self.max_snapshot_bytes is None and not over_rudp:
return None return None
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID) alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)

View file

@ -27,6 +27,7 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes
if TYPE_CHECKING: if TYPE_CHECKING:
from .server import CultMeshLocalServer from .server import CultMeshLocalServer
from .write_authority import CultMeshRemoteWriteAuthority
@dataclass(frozen=True) @dataclass(frozen=True)
@ -83,6 +84,7 @@ class CultMeshGameSession:
def serve( def serve(
self, self,
*, *,
write_authority: CultMeshRemoteWriteAuthority | None = None,
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 0, port: int = 0,
display_name: str | None = None, display_name: str | None = None,
@ -96,6 +98,7 @@ class CultMeshGameSession:
verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(), verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(),
peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(), peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(),
observation_hub=self.observation_hub if self._serve_simulation_observations else None, observation_hub=self.observation_hub if self._serve_simulation_observations else None,
write_authority=write_authority,
host=host, host=host,
port=port, port=port,
display_name=display_name, display_name=display_name,

View file

@ -0,0 +1,66 @@
"""The one question a CultMesh server asks before it applies a peer's write.
Mirrors C# ``ICultNetRemoteWriteAuthority``: a server with no authority refuses every peer put and
delete, so serving a node never lets a connected peer overwrite its documents by default.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class CultMeshRemoteWriteOperation(Enum):
PUT = "put"
DELETE = "delete"
@dataclass(frozen=True)
class CultMeshServerPeer:
"""The connection a write arrived on: its endpoint and transport, nothing the peer claimed."""
endpoint: str
transport: str
@dataclass(frozen=True)
class CultMeshRemoteWriteRequest:
"""One peer write as the authority sees it.
``schema_id`` is the canonical id the node resolved for the write, never an alias the peer sent.
``shard_id`` and ``shard_epoch`` are as the peer sent them.
"""
peer: CultMeshServerPeer
operation: CultMeshRemoteWriteOperation
schema_id: str
record_key: str
shard_id: str | None = None
shard_epoch: int | None = None
@dataclass(frozen=True)
class CultMeshRemoteWriteDecision:
is_granted: bool
reason: str | None = None
@staticmethod
def grant() -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision(True)
@staticmethod
def refuse(reason: str | None = None) -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision(False, reason)
class CultMeshRemoteWriteAuthority(Protocol):
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: ...
class CultMeshGrantAllWrites:
"""Grants every peer write. For a host that has already authenticated its peers (or a test rig); it is
an explicit choice because serving without an authority refuses writes."""
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
return CultMeshRemoteWriteDecision.grant()

View file

@ -0,0 +1,217 @@
"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python.
`authority-route-vectors.json` is written by the C# reference (signatures are
captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward
inputs. Each negative case fails when the matching check is removed from
`cultmesh_py.authority`.
"""
from __future__ import annotations
import base64
import json
import unittest
from dataclasses import replace
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric import ec
from cultmesh_py.authority import (
AUTHENTICATED_REMOTE,
LOCAL_DEVELOPMENT,
AuthorityRoute,
AuthorityRouteRefusal,
AuthorityTrustPolicy,
P256PublicKey,
RouteCertificate,
SessionAccepted,
SessionOpen,
canonical_route,
verify_session_proof,
)
CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh"
def _key(element: dict) -> P256PublicKey:
return P256PublicKey(element["keyId"], element["x"], element["y"])
class Vector:
def __init__(self, name: str) -> None:
doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8"))
self.now = doc["nowUnixMilliseconds"]
self.root = _key(doc["odinRoot"])
r = doc["route"]
self.verse_id = r["verseId"]
c = r["certificate"]
self.certificate = RouteCertificate(
_key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"],
c["expiresAtUnixMilliseconds"], c["signature"],
)
self.route = AuthorityRoute(
r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"],
r["generation"], self.certificate,
)
p = doc["sessionProof"]
q = p["request"]
self.request = SessionOpen(
q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"],
q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"],
)
self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"])
self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root])
def with_certificate(self, **changes) -> AuthorityRoute:
return replace(self.route, certificate=replace(self.certificate, **changes))
def refusal(test: unittest.TestCase, fn) -> str:
with test.assertRaises(AuthorityRouteRefusal) as raised:
fn()
test.assertEqual("authentication", raised.exception.reason)
return raised.exception.message
class AuthorityRouteVectorTests(unittest.TestCase):
def test_cs_written_vector_verifies_route_and_session_proof(self) -> None:
v = Vector("authority-route-vectors.json")
v.trust.validate(v.verse_id, v.route, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None:
v = Vector("authority-route-vectors.ts-signed.json")
v.trust.validate(v.verse_id, v.route, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now))
tampered = replace(v.route, priority=v.route.priority + 1)
self.assertEqual(
"The Odin route certificate signature is invalid.",
refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)),
)
def test_canonical_route_is_length_prefixed_and_sorted(self) -> None:
v = Vector("authority-route-vectors.json")
data = canonical_route(v.verse_id, v.route)
self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"))
self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data)
def test_tampered_payload_fields_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
invalid = "The Odin route certificate signature is invalid."
for tampered, verse in (
(replace(v.route, priority=8), v.verse_id),
(replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id),
(replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id),
(replace(v.route, generation="other"), v.verse_id),
(v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id),
(v.route, "other-verse"),
):
self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now)))
def test_wrong_root_key_is_refused(self) -> None:
v = Vector("authority-route-vectors.json")
numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers()
impostor = P256PublicKey(
v.root.key_id,
base64.b64encode(numbers.x.to_bytes(32, "big")).decode(),
base64.b64encode(numbers.y.to_bytes(32, "big")).decode(),
)
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor])
self.assertEqual(
"The Odin route certificate signature is invalid.",
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
)
def test_untrusted_odin_key_id_is_refused(self) -> None:
v = Vector("authority-route-vectors.json")
trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")])
self.assertEqual(
"Odin key 'odin-vector' is not trusted by this consumer.",
refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)),
)
def test_expired_and_not_yet_valid_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
window = "The Odin route certificate is not currently valid."
expires = v.certificate.expires_at_unix_milliseconds
issued = v.certificate.issued_at_unix_milliseconds
v.trust.validate(v.verse_id, v.route, expires - 1)
v.trust.validate(v.verse_id, v.route, issued)
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires)))
self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1)))
def test_unsigned_and_unprotected_routes_are_refused(self) -> None:
v = Vector("authority-route-vectors.json")
unsigned = replace(v.route, certificate=None)
self.assertEqual(
"Remote CultMesh routes require an Odin-signed authority certificate.",
refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)),
)
blank = v.with_certificate(signature=" ")
self.assertEqual(
"Remote CultMesh routes require an Odin-signed authority certificate.",
refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)),
)
plain = replace(v.route, endpoint="ws://provider.example/mesh")
self.assertEqual(
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)),
)
def test_local_development_admits_only_unsigned_loopback(self) -> None:
v = Vector("authority-route-vectors.json")
dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, [])
loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh")
dev.validate(v.verse_id, loop, v.now)
self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now))
for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"):
refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now))
authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [])
refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now))
def test_malformed_signatures_get_their_own_refusals(self) -> None:
v = Vector("authority-route-vectors.json")
not_base64 = v.with_certificate(signature="!!not base64!!")
self.assertEqual(
"The Odin route signature is not base64.",
refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)),
)
short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode())
self.assertEqual(
"The Odin route signature is not IEEE P1363 P-256.",
refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)),
)
def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None:
v = Vector("authority-route-vectors.json")
order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
raw = base64.b64decode(v.certificate.signature)
s = int.from_bytes(raw[32:], "big")
flipped = raw[:32] + (order - s).to_bytes(32, "big")
high = v.with_certificate(signature=base64.b64encode(flipped).decode())
v.trust.validate(v.verse_id, high, v.now)
def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None:
v = Vector("authority-route-vectors.json")
replayed = replace(v.request, client_nonce="replayed")
accepted = replace(v.accepted, client_nonce="replayed")
self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now))
self.assertFalse(verify_session_proof(
v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now))
raw = bytearray(base64.b64decode(v.accepted.provider_signature))
raw[0] ^= 1
forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode())
self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now))
moved = replace(v.request, protocol_id="cultmesh.content.v1")
self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now))
# An expired route fails closed even with a good proof.
refusal(self, lambda: verify_session_proof(
v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,52 @@
"""Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes only on
its result frame (write_not_authorized, or the success reply).
The Python write path is a subscription over RUDP: `send` queues the put, `read_next` returns the result frame
and raises the peer's refusal. The server here refuses the put; its reliable ack of the put arrives (the test
waits for it) and the client still reports the refusal, never success. The transport's `receive` yields data
frames only, so the order of the ack against the refusal cannot change what `read_next` reports.
"""
from __future__ import annotations
import unittest
from cultcache_py import define_database_entry_type
from cultmesh_py import CultMesh, create_node
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetDatabaseSubscription, CultNetPeerError, create_rudp_schema_transport
from test_remote_write_authority import KEY_A, NOTE, SCHEMA_ID, put_of
class ClientWriteCompletionTests(unittest.TestCase):
def test_a_put_the_server_refused_is_reported_refused_after_its_ack_arrived(self) -> None:
node = create_node(runtime_id="completion-server")
node.database.register_document(NOTE)
server = CultMeshLocalServer(node=node, port=0, write_authority=None)
server.start()
try:
subscription = CultNetDatabaseSubscription(
host="127.0.0.1",
port=server.port,
timeout_seconds=4.0,
subscription_id="completion",
schema_ids=[SCHEMA_ID],
include_snapshot=False,
create_transport=lambda: create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
),
)
with subscription as sub:
sub.send(put_of(KEY_A, "peer"))
sub._transport.flush_reliable(2.0) # the put's reliable ack has arrived
with self.assertRaises(CultNetPeerError) as raised:
sub.read_next()
finally:
server.stop()
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
self.assertIsNone(server.node.cache.get(NOTE, KEY_A))
if __name__ == "__main__":
unittest.main()

View file

@ -24,7 +24,9 @@ from cultcache_py import (
define_document_type, define_document_type,
) )
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
from cultmesh_py.server import MAX_CONSECUTIVE_REFUSED_FRAMES
from cultmesh_py.verify import verify from cultmesh_py.verify import verify
from cultmesh_py.write_authority import CultMeshRemoteWriteDecision
from cultnet_py import ( from cultnet_py import (
compute_simulation_claim_hash, compute_simulation_claim_hash,
CultNetClientAuthorityScope, CultNetClientAuthorityScope,
@ -68,7 +70,7 @@ from cultnet_py import (
snapshot_request, snapshot_request,
) )
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
from cultmesh_py import create_node from cultmesh_py import CultMeshGrantAllWrites, create_node
from cultmesh_py import ( from cultmesh_py import (
CultMesh, CultMesh,
CultMeshDatabase, CultMeshDatabase,
@ -1631,7 +1633,7 @@ class CultMeshTests(unittest.TestCase):
) )
primary = CultMesh.create_node(runtime_id="forward-primary") primary = CultMesh.create_node(runtime_id="forward-primary")
primary.database.register_document(document) primary.database.register_document(document)
server = CultMesh.serve_node(primary) server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites())
try: try:
shard = CultNetShardDescriptor( shard = CultNetShardDescriptor(
shard_id="forward", shard_id="forward",
@ -3015,7 +3017,7 @@ class CultMeshTests(unittest.TestCase):
source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
target = CultMesh.create_node(runtime_id="subscription-alias-target") target = CultMesh.create_node(runtime_id="subscription-alias-target")
target.database.register_document(document) target.database.register_document(document)
server = CultMesh.serve_node(source) server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription: with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
@ -3533,6 +3535,7 @@ class CultMeshTests(unittest.TestCase):
sys.executable, sys.executable,
"-m", "-m",
"cultmesh_py.daemon", "cultmesh_py.daemon",
"--allow-peer-writes",
"--runtime-id", "--runtime-id",
"live-daemon-peer", "live-daemon-peer",
"--port", "--port",
@ -3723,6 +3726,7 @@ class CultMeshTests(unittest.TestCase):
sys.executable, sys.executable,
"-m", "-m",
"cultmesh_py.daemon", "cultmesh_py.daemon",
"--allow-peer-writes",
"--runtime-id", "--runtime-id",
"forwarder-daemon-peer", "forwarder-daemon-peer",
"--port", "--port",
@ -3998,6 +4002,7 @@ class CultMeshTests(unittest.TestCase):
server = CultMesh.serve_node( server = CultMesh.serve_node(
node, node,
verse_catalog=verses, verse_catalog=verses,
write_authority=CultMeshGrantAllWrites(),
peer_catalog=peers, peer_catalog=peers,
observation_hub=observation_hub, observation_hub=observation_hub,
display_name="Mesh Server", display_name="Mesh Server",
@ -4274,7 +4279,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with raw_client.subscribe_database( with raw_client.subscribe_database(
@ -4323,7 +4328,7 @@ class CultMeshTests(unittest.TestCase):
shard_epoch=1, shard_epoch=1,
) )
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with raw_client.subscribe_database( with raw_client.subscribe_database(
@ -4420,7 +4425,7 @@ class CultMeshTests(unittest.TestCase):
def served_alone_bytes(key: str, body_length: int) -> int: def served_alone_bytes(key: str, body_length: int) -> int:
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe") node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, enable_rudp=False) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), enable_rudp=False)
try: try:
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot) response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
finally: finally:
@ -4434,7 +4439,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-served-bound") node = CultMesh.create_node(runtime_id="mesh-served-bound")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=limit, enable_rudp=False)
try: try:
refused, _ = exchange(server, put("put-over", "note:over", 101)) refused, _ = exchange(server, put("put-over", "note:over", 101))
stored_after_refusal = node.database.get(document, "note:over") stored_after_refusal = node.database.get(document, "note:over")
@ -4498,7 +4503,7 @@ class CultMeshTests(unittest.TestCase):
document = self._rudp_bound_note() document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-large") node = CultMesh.create_node(runtime_id="mesh-rudp-large")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
served, payload = self._rudp_exchange( served, payload = self._rudp_exchange(
server, server,
@ -4524,7 +4529,7 @@ class CultMeshTests(unittest.TestCase):
def served_alone_bytes(body_length: int) -> int: def served_alone_bytes(body_length: int) -> int:
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe") node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
_, payload = self._rudp_exchange( _, payload = self._rudp_exchange(
server, self._rudp_note_put(document, "probe", key, body_length), server, self._rudp_note_put(document, "probe", key, body_length),
@ -4554,7 +4559,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-rudp-bound") node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
try: try:
refusals = [ refusals = [
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0] self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
@ -4792,7 +4797,7 @@ class CultMeshTests(unittest.TestCase):
body_for = self._rudp_body_for(document, "note:w") body_for = self._rudp_body_for(document, "note:w")
node = CultMesh.create_node(runtime_id="mesh-rudp-wide") node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=1)
try: try:
transport = create_rudp_schema_transport( transport = create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
@ -4857,7 +4862,7 @@ class CultMeshTests(unittest.TestCase):
document = self._rudp_bound_note() document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed") node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
node.database.register_document(document) node.database.register_document(document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
refusals = [] refusals = []
for field_name, value in ( for field_name, value in (
@ -4880,11 +4885,146 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in refusals: for message_id, refusal in refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id) self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertEqual(after["messageId"], "after") self.assertEqual(after["messageId"], "after")
self.assertEqual(after["documents"], []) self.assertEqual(after["documents"], [])
self.assertTrue(alive) self.assertTrue(alive)
def _rudp_open(self, server: Any) -> Any:
return create_rudp_schema_transport(
host="127.0.0.1",
port=server.port,
connection_id=server.rudp_connection_id,
timeout_seconds=4.0,
max_fragment_bytes=1200,
)
def _rudp_answer(self, transport: Any, payload: bytes) -> dict[str, Any]:
transport.send("schema", payload)
return msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
def _assert_rudp_session_ended(self, transport: Any, server: Any) -> None:
transport.send("schema", msgpack.packb(snapshot_request(message_id="after-cap").to_wire(), use_bin_type=True))
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
self.assertTrue(server._rudp_thread.is_alive())
def test_cultmesh_local_server_answers_unreadable_frames_and_ends_the_session_at_eight_in_a_row(self) -> None:
# An undecodable frame (not a msgpack map) is answered message_invalid and the session kept;
# eight in a row end it (the eighth is answered too); any decoded frame resets the run.
self.assertEqual(MAX_CONSECUTIVE_REFUSED_FRAMES, 8)
node = CultMesh.create_node(runtime_id="mesh-rudp-unreadable")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
garbage = b"\xc1"
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
for _ in range(2):
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
reply = self._rudp_answer(transport, garbage)
self.assertEqual(reply["code"], "message_invalid")
self.assertEqual(reply["details"]["field"], "message")
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, garbage)
transport.send("schema", garbage)
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_resends_a_lost_capped_answer_before_it_ends_the_session(self) -> None:
# The eighth refusal is answered reliably: the datagram carrying it is lost on the wire, and the
# peer still gets the answer (by resend) before the goodbye. An unreliable final answer, or a
# goodbye sent at once, would leave the peer without it.
node = CultMesh.create_node(runtime_id="mesh-rudp-lost-answer")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
send = server._send_rudp_packet
dropped: list[Any] = []
armed = threading.Event()
def lossy(rudp_socket: Any, remote_addr: Any, packet: Any) -> Any:
if armed.is_set() and not dropped and packet.packet_type == CultNetRudpPacketType.DATA and b"message_invalid" in (packet.payload or b""):
dropped.append(packet)
return None
return send(rudp_socket, remote_addr, packet)
server._send_rudp_packet = lossy
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
armed.set()
reply = self._rudp_answer(transport, b"\xc1")
self.assertEqual(len(dropped), 1, "fixture: the first transmission of the last answer was lost")
self.assertEqual(reply["code"], "message_invalid")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_serves_nothing_after_the_capped_frame(self) -> None:
node = CultMesh.create_node(runtime_id="mesh-rudp-after-cap")
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
transport = self._rudp_open(server)
try:
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
self._rudp_answer(transport, b"\xc1")
transport.send("schema", b"\xc1")
transport.send("schema", msgpack.packb(snapshot_request(message_id="behind-the-cap").to_wire(), use_bin_type=True))
first = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(first["code"], "message_invalid")
with self.assertRaises(Exception):
transport.receive(timeout_seconds=1.0)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_ends_the_session_at_eight_refused_deletes_in_a_row(self) -> None:
class GrantOnlyKeyB:
def decide(self, request: Any) -> Any:
return CultMeshRemoteWriteDecision.grant() if request.record_key == "note:b" else CultMeshRemoteWriteDecision.refuse("no")
document = self._rudp_bound_note()
node = CultMesh.create_node(runtime_id="mesh-rudp-deletes")
node.database.register_document(document)
server = CultMesh.serve_node(node, write_authority=GrantOnlyKeyB())
def delete(key: str) -> bytes:
return msgpack.packb({
"schemaVersion": "cultnet.document_delete.v0",
"messageId": f"del-{key}",
"schemaId": document.schema_id,
"recordKey": key,
}, use_bin_type=True)
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
transport = self._rudp_open(server)
try:
def refused_run(count: int) -> None:
for _ in range(count):
reply = self._rudp_answer(transport, delete("note:a"))
self.assertEqual(reply["code"], "write_not_authorized")
self.assertEqual(reply["details"]["value"], "note:a")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# A granted delete resets the run (nothing is stored under it, so the store answers that).
self.assertEqual(self._rudp_answer(transport, delete("note:b"))["code"], "unregistered_document_delete")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
# So does any decoded non-delete frame.
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
transport.send("schema", delete("note:a"))
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
self.assertEqual(eighth["code"], "write_not_authorized")
self._assert_rudp_session_ended(transport, server)
finally:
transport.close()
server.stop()
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None: def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a # Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
# payload its schema cannot decode, a global document under another key. Each is refused # payload its schema cannot decode, a global document under another key. Each is refused
@ -4917,7 +5057,7 @@ class CultMeshTests(unittest.TestCase):
node = CultMesh.create_node(runtime_id="mesh-unstorable-put") node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
node.database.register_document(document) node.database.register_document(document)
node.database.register_document(global_document) node.database.register_document(global_document)
server = CultMesh.serve_node(node) server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
try: try:
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()] rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire()) rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
@ -4939,7 +5079,8 @@ class CultMeshTests(unittest.TestCase):
for message_id, refusal in rudp_refusals + tcp_refusals: for message_id, refusal in rudp_refusals + tcp_refusals:
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
self.assertEqual(refusal["messageId"], message_id) self.assertEqual(refusal["messageId"], message_id)
self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertEqual(refusal["code"], "message_invalid", message_id)
self.assertEqual(refusal["details"]["field"], "document", message_id)
self.assertNotIn("note:ok", refusal["error"], message_id) self.assertNotIn("note:ok", refusal["error"], message_id)
self.assertEqual(len(tcp_refusals), 5) self.assertEqual(len(tcp_refusals), 5)
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0") self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
@ -5048,17 +5189,18 @@ class CultMeshTests(unittest.TestCase):
finally: finally:
server.stop() server.stop()
self.assertEqual(unsupported["messageId"], "bad-schema")
self.assertEqual(unsupported["code"], "unsupported_schema_version") self.assertEqual(unsupported["code"], "unsupported_schema_version")
self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") self.assertEqual(unsupported["details"]["field"], "schemaVersion")
self.assertIn("Unsupported CultNet message schema", unsupported["error"]) self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
self.assertIn("unsupported_schema_version", unsupported["error"])
self.assertEqual(malformed_put["messageId"], "bad-put") self.assertEqual(malformed_put["messageId"], "bad-put")
self.assertEqual(malformed_put["code"], "malformed_document_put") self.assertEqual(malformed_put["code"], "message_invalid")
self.assertEqual(malformed_put["details"]["field"], "document")
self.assertEqual(malformed_delete["messageId"], "bad-delete") self.assertEqual(malformed_delete["messageId"], "bad-delete")
self.assertEqual(malformed_delete["code"], "malformed_document_delete") self.assertEqual(malformed_delete["code"], "message_invalid")
self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection") self.assertEqual(malformed_delete["details"]["field"], "recordKey")
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version") self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1") self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")
def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
peer = CultMeshPeerCard( peer = CultMeshPeerCard(

View file

@ -0,0 +1,338 @@
"""A peer's put or delete is applied only when the server's write authority grants it.
Ports the behaviours of C# RemoteWriteAuthorityTests. The C# forwarding behaviour (a refused write is never
forwarded) has no counterpart: this server does not forward non-primary writes. The handlers are reached with
the peer the connection loop would pass, and one test drives the same path over a real TCP connection.
"""
from __future__ import annotations
import json
import unittest
from pathlib import Path
from typing import Any
import msgpack
from cultcache_py import define_database_entry_type
from cultmesh_py import (
CultMesh,
CultMeshGrantAllWrites,
CultMeshRemoteWriteDecision,
CultMeshRemoteWriteOperation,
CultMeshRemoteWriteRequest,
CultMeshServerPeer,
create_node,
)
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message
SCHEMA_ID = "sha256:authority-note"
ALIAS = "runtime.generated.authority-note.ui.42"
SHARD_ID = "authority-shard"
KEY_A = "tests:authority:a"
KEY_B = "tests:authority:b"
PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp")
_REPO = Path(__file__).resolve().parents[3]
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
NOTE = define_database_entry_type(
"tests.authority_note",
[("schema_version", 0), ("text", 1)],
schema_id=SCHEMA_ID,
schema_name="tests.authority_note",
schema_version="tests.authority_note.v1",
)
class Recording:
def __init__(self, decide: Any) -> None:
self._decide = decide
self.requests: list[CultMeshRemoteWriteRequest] = []
def decide(self, request: CultMeshRemoteWriteRequest) -> Any:
self.requests.append(request)
return self._decide(request)
def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
return document_put_raw(
message_id="wire",
key=key,
schema_id=schema_id,
stored_at="2026-11-07T00:00:00Z",
payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}),
shard_id=SHARD_ID,
shard_epoch=1,
).to_wire()
def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
return document_delete(
message_id="wire-delete", schema_id=schema_id, record_key=key, shard_id=SHARD_ID, shard_epoch=1
).to_wire()
class RemoteWriteAuthorityTests(unittest.TestCase):
def build(self, authority: Any = None) -> CultMeshLocalServer:
node = create_node(runtime_id="authority-server")
node.database.register_document(NOTE)
return CultMeshLocalServer(node=node, port=0, write_authority=authority)
def text_of(self, server: CultMeshLocalServer, key: str) -> str | None:
value = server.node.cache.get(NOTE, key)
return None if value is None else value["text"]
def send(self, server: CultMeshLocalServer, message: dict[str, Any]) -> list[dict[str, Any]]:
subscriptions: dict[str, Any] = {}
return server._handle_connection_message(message, subscriptions, peer=PEER)
def assert_refused(self, replies: list[dict[str, Any]], key: str, *, error: str | None = None) -> None:
self.assertEqual(len(replies), 1)
reply = replies[0]
self.assertEqual(reply["schemaVersion"], "cultnet.error.v0")
self.assertEqual(reply["code"], "write_not_authorized")
self.assertTrue(reply["error"].startswith("write_not_authorized"))
self.assertEqual(reply["details"]["field"], "recordKey")
self.assertEqual(reply["details"]["value"], key)
if error is not None:
self.assertEqual(reply["error"], error)
def seed(self, server: CultMeshLocalServer, key: str, text: str) -> None:
# The host writes its own record through its own door, which asks no remote authority.
self.assertEqual(server.node.database.apply_raw_put_message(put_of(key, text)).change_kind, "added")
def test_with_no_authority_a_peers_put_and_delete_are_refused_and_change_nothing(self) -> None:
server = self.build(None)
self.seed(server, KEY_A, "server")
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
self.assertEqual(self.text_of(server, KEY_A), "server")
self.assertIsNone(self.text_of(server, KEY_B))
self.assertEqual(sum("write not authorized" in line for line in logs.output), 3)
def test_a_refusal_publishes_no_change_to_a_subscriber(self) -> None:
server = self.build(None)
server.start()
try:
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
with self.assertRaises(CultNetPeerError) as raised:
sub.send(put_of(KEY_A, "peer"))
sub.read_next()
finally:
server.stop()
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
self.assertIsNone(self.text_of(server, KEY_A))
def test_a_granted_put_and_delete_commit_and_the_authority_saw_this_peer(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
put_replies = self.send(server, put_of(KEY_A, "peer"))
self.assertEqual(self.text_of(server, KEY_A), "peer")
self.assertEqual(put_replies, [])
self.send(server, delete_of(KEY_A))
self.assertIsNone(self.text_of(server, KEY_A))
self.assertEqual(len(authority.requests), 2)
self.assertTrue(all(request.peer is PEER for request in authority.requests))
def test_the_request_carries_the_resolved_write_and_scoped_grants_refuse_their_neighbours(self) -> None:
authority = Recording(
lambda request: CultMeshRemoteWriteDecision.grant()
if request.operation is CultMeshRemoteWriteOperation.PUT and request.record_key == KEY_A
else CultMeshRemoteWriteDecision.refuse("scoped")
)
server = self.build(authority)
self.seed(server, KEY_A, "server")
self.send(server, put_of(KEY_A, "aliased", schema_id=ALIAS))
request = authority.requests[0]
self.assertEqual(request.schema_id, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent")
self.assertIs(request.operation, CultMeshRemoteWriteOperation.PUT)
self.assertEqual(request.record_key, KEY_A)
self.assertEqual(request.shard_id, SHARD_ID)
self.assertEqual(request.shard_epoch, 1)
self.assertEqual(self.text_of(server, KEY_A), "aliased")
self.assert_refused(self.send(server, put_of(KEY_B, "other-key")), KEY_B)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assertIsNone(self.text_of(server, KEY_B), "a grant scoped to key a refuses key b")
self.assertEqual(self.text_of(server, KEY_A), "aliased", "a grant scoped to put refuses delete")
self.assertIs(authority.requests[-1].operation, CultMeshRemoteWriteOperation.DELETE)
self.assertEqual(authority.requests[-1].schema_id, SCHEMA_ID)
def test_refusal_precedes_every_other_judgment_of_the_put(self) -> None:
refusing = self.build(None)
unowned = put_of("unrelated:authority", "x", schema_id="sha256:nobody-owns-this")
unowned["document"]["payload"] = msgpack.packb(["nobody.owns.this.v1", "x"], use_bin_type=True)
self.assert_refused(self.send(refusing, unowned), "unrelated:authority")
blank_key = put_of("", "x")
self.assert_refused(self.send(refusing, blank_key), "")
granting = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.grant()))
reply = self.send(granting, unowned)
self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal")
def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None:
def explode(_: Any) -> Any:
raise RuntimeError("authority exploded")
server = self.build(Recording(explode))
self.seed(server, KEY_A, "server")
with self.assertLogs("cultmesh_py.server", level="ERROR") as logs:
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assertEqual(self.text_of(server, KEY_A), "server", "fail closed")
self.assertTrue(any("RuntimeError" in line for line in logs.output))
def test_a_refusal_with_no_reason_still_says_why(self) -> None:
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse()))
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A, error="write_not_authorized")
def test_the_refusal_names_the_key_and_the_code_in_the_pinned_bytes(self) -> None:
vector = next(v for v in _VECTORS if v["name"] == "write_not_authorized")
message = error_message(
"write_not_authorized: not granted",
code="write_not_authorized",
details={"field": "recordKey", "value": "some:key"},
)
self.assertEqual(message.to_bytes(), bytes.fromhex(vector["messagePackHex"]))
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse("not granted")))
reply = self.send(server, put_of("some:key", "peer"))[0]
self.assertEqual(msgpack.packb(reply, use_bin_type=True), bytes.fromhex(vector["messagePackHex"]))
def test_the_peer_receives_the_typed_refusal_over_tcp_and_a_grant_sends_nothing_back(self) -> None:
authority = Recording(
lambda request: CultMeshRemoteWriteDecision.grant()
if request.record_key == KEY_A
else CultMeshRemoteWriteDecision.refuse("only a")
)
server = self.build(authority)
server.start()
try:
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
sub.send(put_of(KEY_A, "peer"))
self.assertEqual(sub.read_next_change().record_key, KEY_A, "the granted write was answered by its change, not an error")
sub.send(put_of(KEY_B, "peer"))
with self.assertRaises(CultNetPeerError) as raised:
sub.read_next()
finally:
server.stop()
reply = raised.exception.response
self.assertEqual(reply["error"], "write_not_authorized: only a")
self.assertEqual(reply["details"]["value"], KEY_B)
self.assertTrue(authority.requests[0].peer.endpoint.startswith("127.0.0.1:"))
self.assertEqual(authority.requests[0].peer.transport, "tcp")
self.assertEqual(self.text_of(server, KEY_A), "peer")
self.assertIsNone(self.text_of(server, KEY_B))
def test_a_delete_sent_under_a_schema_alias_asks_the_authority_with_the_canonical_id(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.seed(server, KEY_A, "server")
self.send(server, delete_of(KEY_A, schema_id="tests.authority_note.v1"))
request = authority.requests[0]
self.assertIs(request.operation, CultMeshRemoteWriteOperation.DELETE)
self.assertEqual(request.schema_id, SCHEMA_ID)
self.assertIsNone(self.text_of(server, KEY_A), "the aliased delete was granted and applied")
def test_a_missing_decision_refuses_put_and_delete(self) -> None:
server = self.build(Recording(lambda _: None))
self.seed(server, KEY_A, "server")
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
self.assertEqual(self.text_of(server, KEY_A), "server")
self.assertIsNone(self.text_of(server, KEY_B))
def test_a_peer_over_rudp_is_refused_with_its_rudp_endpoint_and_the_server_serves_on(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.refuse("rudp no"))
server = self.build(authority)
server.start()
try:
transport = create_rudp_schema_transport(
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
)
try:
transport.send("schema", msgpack.packb(put_of(KEY_A, "peer"), use_bin_type=True))
reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
transport.send("schema", msgpack.packb(delete_of(KEY_A), use_bin_type=True))
delete_reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
finally:
transport.close()
finally:
server.stop()
self.assertEqual(reply["error"], "write_not_authorized: rudp no")
self.assertEqual(delete_reply["code"], "write_not_authorized")
self.assertEqual([r.peer.transport for r in authority.requests], ["rudp", "rudp"])
self.assertTrue(all(r.peer.endpoint.startswith("127.0.0.1:") for r in authority.requests))
self.assertIsNone(self.text_of(server, KEY_A))
def test_a_put_with_an_absurd_payload_is_refused_by_the_gate_not_crashed_on(self) -> None:
server = self.build(None)
absurd = put_of(KEY_A, "x")
absurd["document"]["payload"] = 2**70
self.assert_refused(self.send(server, absurd), KEY_A)
def test_an_ungranted_peers_huge_payload_is_refused_without_allocating_it(self) -> None:
import tracemalloc
server = self.build(None)
huge = put_of(KEY_A, "x")
huge["document"]["payload"] = 10**9 # bytes(10**9) would allocate a gigabyte
tracemalloc.start()
try:
replies = self.send(server, huge)
_, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
self.assert_refused(replies, KEY_A)
self.assertLess(peak, 10_000_000)
def test_wrong_typed_shard_fields_are_refused_before_the_gate_and_write_nothing(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.seed(server, KEY_B, "server")
shards_before = server.node.database.shard_ids()
for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)):
for operation in ("put", "delete"):
with self.subTest(field=field, value=value, operation=operation):
message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B)
message[field] = value
replies = self.send(server, message)
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", field)])
self.assertEqual(authority.requests, [], "the authority was never asked")
self.assertIsNone(self.text_of(server, KEY_A), "nothing was written")
self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted")
self.assertEqual(server.node.database.shard_ids(), shards_before, "no shard or log entry was filed")
def test_well_typed_shard_fields_reach_the_authority_as_sent(self) -> None:
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
server = self.build(authority)
self.send(server, put_of(KEY_A, "x"))
self.assertEqual((authority.requests[0].shard_id, authority.requests[0].shard_epoch), (SHARD_ID, 1))
def test_grant_all_is_an_explicit_choice_that_grants_every_write(self) -> None:
server = self.build(CultMeshGrantAllWrites())
self.send(server, put_of(KEY_A, "peer"))
self.assertEqual(self.text_of(server, KEY_A), "peer")
if __name__ == "__main__":
unittest.main()

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
import msgpack import msgpack
from cultcache_py import define_database_entry_type from cultcache_py import define_database_entry_type
from cultmesh_py import create_node from cultmesh_py import CultMeshGrantAllWrites, create_node
from cultmesh_py.server import CultMeshLocalServer from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import ( from cultnet_py import (
CultNetRudpSendOptions, CultNetRudpSendOptions,
@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase):
node = create_node(runtime_id="rudp-generation") node = create_node(runtime_id="rudp-generation")
node.database.register_document(NOTE) node.database.register_document(NOTE)
# Replies stay whole datagrams here, so each server packet the pump reads is one message. # Replies stay whole datagrams here, so each server packet the pump reads is one message.
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000) self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000, write_authority=CultMeshGrantAllWrites())
self.server.start() self.server.start()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind(("127.0.0.1", 0)) self.sock.bind(("127.0.0.1", 0))

View file

@ -0,0 +1,69 @@
"""A server asked for any port finds a TCP+UDP pair; one asked for a port gets that port or an honest failure."""
from __future__ import annotations
import errno
import socket
import threading
import unittest
from unittest.mock import patch
from cultmesh_py import create_node
from cultmesh_py.server import CultMeshLocalServer
def server(**kwargs) -> CultMeshLocalServer:
return CultMeshLocalServer(node=create_node(runtime_id="bind-test"), host="127.0.0.1", **kwargs)
class ServerBindTests(unittest.TestCase):
def test_port_zero_retries_with_a_fresh_port_when_another_process_holds_the_udp_number(self) -> None:
real_bind = socket.socket.bind
refused: list[int] = []
def bind(sock: socket.socket, address) -> None:
# The first two UDP binds find the number taken, as a UDP listener of another process would.
if sock.type == socket.SOCK_DGRAM and len(refused) < 2:
refused.append(address[1])
raise OSError(errno.EADDRINUSE, "Address already in use")
real_bind(sock, address)
with patch.object(socket.socket, "bind", bind):
started = server().start()
try:
self.assertEqual(len(refused), 2)
self.assertNotIn(started.port, refused[:1], "the retry used a fresh TCP port")
probe = socket.create_connection(("127.0.0.1", started.port), timeout=2.0)
probe.close()
finally:
started.stop()
def test_an_explicit_port_whose_udp_number_is_taken_fails_and_leaves_nothing_running(self) -> None:
holder = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
holder.bind(("127.0.0.1", 0))
port = holder.getsockname()[1]
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
free.bind(("127.0.0.1", port)) # the TCP number is free: only the UDP one is taken
finally:
free.close()
threads_before = threading.active_count()
try:
failing = server(port=port)
with self.assertRaises(OSError) as raised:
failing.start()
self.assertEqual(raised.exception.errno, errno.EADDRINUSE)
self.assertIsNone(failing._socket)
self.assertEqual(threading.active_count(), threads_before, "no accept thread was left running")
# The TCP number was released, not left listening.
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
free.bind(("127.0.0.1", port))
finally:
free.close()
finally:
holder.close()
if __name__ == "__main__":
unittest.main()

View file

@ -67,6 +67,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -161,6 +167,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -214,11 +232,14 @@ name = "cultmesh-rs"
version = "0.2.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"cultcache-rs", "cultcache-rs",
"cultnet-rs", "cultnet-rs",
"p256",
"pretty_assertions", "pretty_assertions",
"rmp-serde", "rmp-serde",
"serde", "serde",
"serde_json",
"tempfile", "tempfile",
] ]
@ -298,10 +319,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
"subtle", "subtle",
] ]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@ -326,6 +362,25 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@ -342,6 +397,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@ -396,6 +461,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@ -442,6 +508,17 @@ dependencies = [
"polyval", "polyval",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -555,6 +632,18 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -602,6 +691,15 @@ dependencies = [
"yansi", "yansi",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@ -689,6 +787,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]] [[package]]
name = "rmp" name = "rmp"
version = "0.8.15" version = "0.8.15"
@ -747,6 +855,20 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -840,6 +962,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]

View file

@ -13,9 +13,12 @@ path = "src/lib.rs"
anyhow = "1" anyhow = "1"
cultcache-rs = { path = "../cultcache-rs" } cultcache-rs = { path = "../cultcache-rs" }
cultnet-rs = { path = "../cultnet-rs" } cultnet-rs = { path = "../cultnet-rs" }
base64 = "0.22"
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
rmp-serde = "1" rmp-serde = "1"
serde_json = "1"
tempfile = "3" tempfile = "3"

View file

@ -0,0 +1,424 @@
//! CultMesh authority route verification.
//!
//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of
//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same
//! order, the same input yields the same refusal text as
//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root
//! lookup, validity window, signature.
//!
//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written).
use base64::alphabet::STANDARD as STANDARD_ALPHABET;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::Engine;
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, VerifyingKey};
use std::collections::BTreeMap;
use std::fmt;
const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1";
const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1";
// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits.
const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new(
&STANDARD_ALPHABET,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical)
.with_decode_allow_trailing_bits(true),
);
fn base64_decode(value: &str) -> Option<Vec<u8>> {
let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect();
DOTNET_BASE64.decode(stripped).ok()
}
/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`.
fn trim(value: &str) -> &str {
value.trim_matches(char::is_whitespace)
}
fn is_blank(value: &str) -> bool {
trim(value).is_empty()
}
fn require(value: &str, name: &str) -> Result<String, String> {
if is_blank(value) {
Err(format!("Value must be non-empty. (Parameter '{name}')"))
} else {
Ok(trim(value).to_owned())
}
}
/// A route or proof the consumer's trust policy refuses. Mirrors
/// `CultMeshSessionFailure` with reason `Authentication`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityRouteRefusal {
pub message: String,
pub endpoint: String,
}
impl AuthorityRouteRefusal {
pub const REASON: &'static str = "authentication";
}
impl fmt::Display for AuthorityRouteRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for AuthorityRouteRefusal {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct P256PublicKey {
pub key_id: String,
pub x: String,
pub y: String,
}
impl P256PublicKey {
pub fn new(key_id: &str, x: &str, y: &str) -> Result<Self, String> {
let key = Self {
key_id: require(key_id, "keyId")?,
x: require(x, "x")?,
y: require(y, "y")?,
};
for (name, value) in [("x", &key.x), ("y", &key.y)] {
if base64_decode(value).is_none() {
return Err(format!("Coordinate must be base64. (Parameter '{name}')"));
}
}
Ok(key)
}
/// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation.
pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool {
let (Some(x), Some(y), Some(signature)) = (
base64_decode(&self.x),
base64_decode(&self.y),
base64_decode(signature_base64),
) else {
return false;
};
if x.len() != 32 || y.len() != 32 || signature.len() != 64 {
return false;
}
let mut sec1 = Vec::with_capacity(65);
sec1.push(4);
sec1.extend_from_slice(&x);
sec1.extend_from_slice(&y);
let (Ok(key), Ok(signature)) = (
VerifyingKey::from_sec1_bytes(&sec1),
Signature::from_slice(&signature),
) else {
return false;
};
key.verify(payload, &signature).is_ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteCertificate {
pub provider_key: P256PublicKey,
pub odin_key_id: String,
pub issued_at_unix_milliseconds: i64,
pub expires_at_unix_milliseconds: i64,
pub signature: String,
}
impl RouteCertificate {
pub fn new(
provider_key: P256PublicKey,
odin_key_id: &str,
issued_at_unix_milliseconds: i64,
expires_at_unix_milliseconds: i64,
signature: &str,
) -> Result<Self, String> {
if is_blank(odin_key_id) {
return Err("Odin key identity is required. (Parameter 'odinKeyId')".into());
}
if issued_at_unix_milliseconds < 0 {
return Err("issuedAtUnixMilliseconds must not be negative.".into());
}
if expires_at_unix_milliseconds <= issued_at_unix_milliseconds {
return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into());
}
Ok(Self {
provider_key,
odin_key_id: trim(odin_key_id).to_owned(),
issued_at_unix_milliseconds,
expires_at_unix_milliseconds,
signature: trim(signature).to_owned(),
})
}
}
/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityRoute {
pub authority_runtime_id: String,
pub endpoint: String,
pub protocol_ids: Vec<String>,
pub priority: i32,
pub generation: String,
pub certificate: Option<RouteCertificate>,
}
impl AuthorityRoute {
pub fn new(
authority_runtime_id: &str,
endpoint: &str,
protocol_ids: &[&str],
priority: i32,
generation: Option<&str>,
certificate: Option<RouteCertificate>,
) -> Result<Self, String> {
let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?;
let endpoint = require(endpoint, "endpoint")?;
if priority < 0 {
return Err("priority must not be negative.".into());
}
let mut protocols: Vec<String> = protocol_ids
.iter()
.filter(|value| !is_blank(value))
.map(|value| trim(value).to_owned())
.collect();
// StringComparer.Ordinal is UTF-16 code-unit order.
protocols.sort_by_key(|value| value.encode_utf16().collect::<Vec<u16>>());
protocols.dedup();
let generation = match generation {
Some(value) if !is_blank(value) => trim(value).to_owned(),
_ => format!("{authority_runtime_id}@{endpoint}"),
};
Ok(Self {
authority_runtime_id,
endpoint,
protocol_ids: protocols,
priority,
generation,
certificate,
})
}
}
/// The fields of `CultMeshSessionOpenMessage` the session proof covers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionOpen {
pub client_nonce: String,
pub message_id: String,
pub source_runtime_id: String,
pub verse_id: String,
pub authority_runtime_id: String,
pub protocol_id: String,
pub route_generation: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionAccepted {
pub client_nonce: String,
pub provider_key_id: String,
pub provider_signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustMode {
AuthenticatedRemote,
LocalDevelopment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRoute {
pub route: AuthorityRoute,
pub provider_key: Option<P256PublicKey>,
pub is_local_development: bool,
}
pub fn canonical_fields(values: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
for value in values {
out.extend_from_slice(&(value.len() as u32).to_be_bytes());
out.extend_from_slice(value.as_bytes());
}
out
}
pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result<Vec<u8>, String> {
let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?;
let protocols = route.protocol_ids.join("\u{1f}");
let priority = route.priority.to_string();
let issued = certificate.issued_at_unix_milliseconds.to_string();
let expires = certificate.expires_at_unix_milliseconds.to_string();
Ok(canonical_fields(&[
ROUTE_DOMAIN,
verse_id,
&route.authority_runtime_id,
&route.endpoint,
&protocols,
&priority,
&route.generation,
&certificate.provider_key.key_id,
&certificate.provider_key.x,
&certificate.provider_key.y,
&certificate.odin_key_id,
&issued,
&expires,
]))
}
pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec<u8> {
canonical_fields(&[
SESSION_DOMAIN,
&request.client_nonce,
&request.message_id,
&request.source_runtime_id,
&request.verse_id,
&request.authority_runtime_id,
&request.protocol_id,
endpoint,
&request.route_generation,
])
}
/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling.
/// A lenient parse could call a refused endpoint loopback or protected, so the
/// shape rule is the same strict one the TypeScript port applies.
fn split_endpoint(value: &str) -> Option<(String, String)> {
let raw = value.trim_matches(|c: char| c <= ' ');
if raw.contains('\\') {
return None;
}
let (scheme, rest) = raw.split_once("://")?;
let mut scheme_chars = scheme.chars();
if !scheme_chars.next()?.is_ascii_alphabetic()
|| !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return None;
}
let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
if authority.is_empty() {
return None;
}
let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..];
let host = if after_user.starts_with('[') {
&after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)]
} else {
after_user.split(':').next().unwrap_or("")
};
if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') {
return None;
}
Some((
scheme.to_ascii_lowercase(),
host.trim_matches(['[', ']']).to_ascii_lowercase(),
))
}
pub fn is_loopback_endpoint(endpoint: &str) -> bool {
let Some((_, host)) = split_endpoint(endpoint) else {
return false;
};
let octets: Vec<&str> = host.split('.').collect();
matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1")
|| (octets.len() == 4
&& octets[0] == "127"
&& octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit())))
}
pub fn is_protected_endpoint(endpoint: &str) -> bool {
split_endpoint(endpoint)
.is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic"))
}
/// Consumer-owned policy. Fails closed unless a route is valid under it.
pub struct AuthorityTrustPolicy {
mode: TrustMode,
roots: BTreeMap<String, P256PublicKey>,
}
impl AuthorityTrustPolicy {
pub fn new(mode: TrustMode, odin_roots: Vec<P256PublicKey>) -> Result<Self, String> {
let mut roots = BTreeMap::new();
for root in odin_roots {
let key_id = root.key_id.clone();
if roots.insert(key_id.clone(), root).is_some() {
return Err(format!(
"Odin root key id '{key_id}' is listed more than once in the trust policy."
));
}
}
Ok(Self { mode, roots })
}
pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> {
self.verify(verse_id, route, now_unix_milliseconds).map(|_| ())
}
pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<VerifiedRoute, AuthorityRouteRefusal> {
let refuse = |message: &str| AuthorityRouteRefusal {
message: message.to_owned(),
endpoint: route.endpoint.clone(),
};
let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint);
let certificate = match &route.certificate {
Some(certificate) if !is_blank(&certificate.signature) => certificate,
_ if local => {
return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true });
}
_ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")),
};
if !is_protected_endpoint(&route.endpoint) && !local {
return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection."));
}
let Some(root) = self.roots.get(&certificate.odin_key_id) else {
return Err(refuse(&format!(
"Odin key '{}' is not trusted by this consumer.",
certificate.odin_key_id
)));
};
if now_unix_milliseconds < certificate.issued_at_unix_milliseconds
|| now_unix_milliseconds >= certificate.expires_at_unix_milliseconds
{
return Err(refuse("The Odin route certificate is not currently valid."));
}
let Some(signature) = base64_decode(&certificate.signature) else {
return Err(refuse("The Odin route signature is not base64."));
};
if signature.len() != 64 {
return Err(refuse("The Odin route signature is not IEEE P1363 P-256."));
}
let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?;
if !root.verify(&canonical, &certificate.signature) {
return Err(refuse("The Odin route certificate signature is invalid."));
}
Ok(VerifiedRoute {
route: route.clone(),
provider_key: Some(certificate.provider_key.clone()),
is_local_development: false,
})
}
}
/// Verifies the Odin route certificate (refusing) and the provider nonce proof.
pub fn verify_session_proof(
request: &SessionOpen,
response: &SessionAccepted,
verse_id: &str,
route: &AuthorityRoute,
trust: &AuthorityTrustPolicy,
now_unix_milliseconds: i64,
) -> Result<bool, AuthorityRouteRefusal> {
let verified = trust.verify(verse_id, route, now_unix_milliseconds)?;
if verified.is_local_development {
return Ok(true);
}
let Some(key) = verified.provider_key else {
return Ok(false);
};
if response.provider_key_id != key.key_id
|| response.client_nonce != request.client_nonce
|| is_blank(&response.provider_signature)
{
return Ok(false);
}
Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature))
}

View file

@ -25,6 +25,7 @@ use std::time::Duration;
use std::time::Instant; use std::time::Instant;
mod rudp_document_server; mod rudp_document_server;
pub mod authority;
pub use rudp_document_server::*; pub use rudp_document_server::*;
@ -81,7 +82,10 @@ pub struct CultMeshRudpDocumentPublishOptions {
pub target: SocketAddr, pub target: SocketAddr,
pub runtime_id: String, pub runtime_id: String,
pub connect_timeout: Duration, pub connect_timeout: Duration,
/// How long the acknowledgement of every put may take.
pub flush_timeout: Duration, pub flush_timeout: Duration,
/// How long after the acknowledgement a refusal may still arrive; a success frame ends the wait early.
pub result_window: Duration,
pub poll_interval: Duration, pub poll_interval: Duration,
pub resend_delay_ms: u64, pub resend_delay_ms: u64,
pub source_agent_id: Option<String>, pub source_agent_id: Option<String>,
@ -148,6 +152,7 @@ impl Default for CultMeshRudpDocumentPublishOptions {
runtime_id: "cultmesh-rudp-document-publisher".to_string(), runtime_id: "cultmesh-rudp-document-publisher".to_string(),
connect_timeout: Duration::from_secs(3), connect_timeout: Duration::from_secs(3),
flush_timeout: Duration::from_millis(300), flush_timeout: Duration::from_millis(300),
result_window: Duration::from_millis(500),
poll_interval: Duration::from_millis(10), poll_interval: Duration::from_millis(10),
resend_delay_ms: 50, resend_delay_ms: 50,
source_agent_id: None, source_agent_id: None,
@ -864,6 +869,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
/// fixed refusals a CultMesh catalog sends; any other text could carry /// fixed refusals a CultMesh catalog sends; any other text could carry
/// anything, so it is left out. /// anything, so it is left out.
fn recognised_refusal(text: &str) -> String { fn recognised_refusal(text: &str) -> String {
let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text);
if REFUSAL_TEXTS.contains(&text) { if REFUSAL_TEXTS.contains(&text) {
format!(": {text}") format!(": {text}")
} else { } else {
@ -880,77 +886,30 @@ fn unix_millis() -> u128 {
/// Publish one raw CultNet schema message to a RUDP catalog. /// Publish one raw CultNet schema message to a RUDP catalog.
/// ///
/// Each call owns a fresh connection epoch and returns only after the catalog /// A put completes on the catalog's result frame, never on the transport
/// acknowledges reliable application admission. Against a Rust CultMesh /// acknowledgement: an ack proves delivery, not admission. A refusal (a
/// catalog, which never acknowledges a message it refuses, a refusal is /// `cultnet.error.v0` frame) returns an error in either order against the ack,
/// returned as an error, naming the refusal when it is one of the fixed texts /// naming the refusal when it is one of the fixed texts a catalog sends. A
/// such a catalog sends. A catalog that acknowledges a message on receipt and /// catalog that admits a put sends no success frame, so after the ack the call
/// refuses it afterwards (the TypeScript document server does) is reported as /// waits `result_window` for a refusal and reports the put as admitted when
/// having admitted it: the acknowledgement arrives first. /// none comes (interim until the catalog sends a success frame; a success
/// frame ends the wait early). Each call owns a fresh connection epoch.
pub fn publish_cultnet_message_to_rudp_catalog( pub fn publish_cultnet_message_to_rudp_catalog(
message: &cultnet_rs::CultNetMessage, message: &cultnet_rs::CultNetMessage,
options: CultMeshRudpDocumentPublishOptions, options: CultMeshRudpDocumentPublishOptions,
) -> Result<()> { ) -> Result<()> {
if options.runtime_id.trim().is_empty() { publish_cultnet_messages_to_rudp_catalog(std::slice::from_ref(message), options)
anyhow::bail!("runtime_id must be non-empty");
}
let mut client = connect_rudp_client(
options.target,
&options.runtime_id,
options.connect_timeout,
options.poll_interval,
options.resend_delay_ms,
"cultmesh-rudp-document-publisher",
)?;
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
let receipt = client.send_reliable("schema", payload)?;
let flush_deadline = Instant::now() + options.flush_timeout;
while Instant::now() < flush_deadline {
if let Some(frame) = client.receive_once()? {
if frame.channel_id == "schema" {
if let Ok(cultnet_rs::CultNetMessage::Error { error, .. }) =
decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)
{
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
}
}
match client.reliable_send_status(&receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {
let _ = client.disconnect(b"document-acknowledged".to_vec());
return Ok(());
}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => {}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
}
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
} }
/// Publish a batch of raw CultNet schema messages to a RUDP catalog. /// Publish a batch of raw CultNet schema messages to a RUDP catalog on one
/// connection.
/// ///
/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for /// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]:
/// per-message application-level acknowledgement, this sends every message on /// every message acknowledged, then no refusal frame within `result_window`
/// one connection and waits only for a transport-level `flush_reliable`. It /// (or one success frame per message). A refusal of any message in the batch
/// trades per-receipt confirmation for batch throughput; callers that need a /// fails the whole call; the batch cannot hide it. The catalog's answers do not
/// stronger delivery guarantee should use the singular publish path instead. /// name the message they answer, so a failed batch does not say which message
/// was refused or whether the others were admitted.
pub fn publish_cultnet_messages_to_rudp_catalog( pub fn publish_cultnet_messages_to_rudp_catalog(
messages: &[cultnet_rs::CultNetMessage], messages: &[cultnet_rs::CultNetMessage],
options: CultMeshRudpDocumentPublishOptions, options: CultMeshRudpDocumentPublishOptions,
@ -967,11 +926,62 @@ pub fn publish_cultnet_messages_to_rudp_catalog(
"cultmesh-rudp-document-publisher", "cultmesh-rudp-document-publisher",
)?; )?;
let mut receipts = Vec::with_capacity(messages.len());
for message in messages { for message in messages {
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
client.send("schema", payload)?; receipts.push(client.send_reliable("schema", payload)?);
}
let ack_deadline = Instant::now() + options.flush_timeout;
let mut acknowledged_at: Option<Instant> = None;
let mut successes = 0;
loop {
while let Some(frame) = client.receive_once()? {
if frame.channel_id != "schema" {
continue;
}
match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
Ok(cultnet_rs::CultNetMessage::Error { error, .. }) => {
let _ = client.disconnect(b"document-refused".to_vec());
anyhow::bail!(
"CultMesh RUDP catalog {} refused the put{}",
options.target,
recognised_refusal(&error)
);
}
Ok(_) => successes += 1,
Err(_) => {}
}
}
if acknowledged_at.is_none() {
let mut all_acknowledged = true;
for receipt in &receipts {
match client.reliable_send_status(receipt) {
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {}
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
}
cultnet_rs::CultNetRudpReliableSendStatus::Pending => all_acknowledged = false,
}
}
if all_acknowledged {
acknowledged_at = Some(Instant::now());
} else if Instant::now() >= ack_deadline {
let _ = client.disconnect(b"document-unacknowledged".to_vec());
anyhow::bail!(
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
options.target
)
}
}
if let Some(acknowledged_at) = acknowledged_at {
if successes >= messages.len() || acknowledged_at.elapsed() >= options.result_window {
let _ = client.disconnect(b"document-completed".to_vec());
return Ok(());
}
}
client.poll_resends()?;
thread::sleep(options.poll_interval);
} }
client.flush_reliable(options.flush_timeout)
} }
fn connect_rudp_client( fn connect_rudp_client(
@ -1449,6 +1459,141 @@ mod tests {
Ok((target, stop, catalog)) Ok((target, stop, catalog))
} }
/// A catalog that acknowledges every reliable message it receives and refuses it with a typed
/// write_not_authorized, the acknowledgement either before the refusal or after it (the second packet
/// follows the first by 100 ms). The refusal carries no acknowledgement of its own.
/// What the stub catalog sends for a put, and in which order against the transport ack.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CatalogReply {
RefusalThenAck,
AckThenRefusal,
AckThenSuccess,
}
fn acknowledging_refusing_catalog(
reply: CatalogReply,
) -> Result<(
SocketAddr,
std::sync::Arc<std::sync::atomic::AtomicBool>,
thread::JoinHandle<Result<()>>,
)> {
use cultnet_rs::{
CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession,
CultNetRudpSessionOptions, decode_rudp_packet, encode_rudp_packet,
};
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_read_timeout(Some(Duration::from_millis(5)))?;
let target = socket.local_addr()?;
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let catalog_stop = stop.clone();
let catalog = thread::spawn(move || -> Result<()> {
let mut session: Option<CultNetRudpSession> = None;
let mut wire = vec![0_u8; 65_535];
while !catalog_stop.load(std::sync::atomic::Ordering::SeqCst) {
let Ok((bytes, peer)) = socket.recv_from(&mut wire) else {
continue;
};
let Ok(packet) = decode_rudp_packet(&wire[..bytes]) else {
continue;
};
if packet.packet_type == CultNetRudpPacketType::Connect {
let mut accepted = CultNetRudpSession::new(CultNetRudpSessionOptions {
connection_id: packet.connection_id,
initial_sequence: None,
resend_delay_ms: 10_000,
max_pending_reliable_packets: Some(64),
});
let accept = accepted.accept_connect(&packet, 0, Vec::new())?;
socket.send_to(&encode_rudp_packet(&accept)?, peer)?;
session = Some(accepted);
continue;
}
let Some(session) = session.as_mut() else {
continue;
};
let Ok(received) = session.receive(&packet, 1) else {
continue;
};
if !packet.reliable || received.delivered.is_empty() {
continue;
}
let ack = session.create_ack_for_received(packet.sequence);
let refusal = encode_cultnet_message_to_vec(
&if reply == CatalogReply::AckThenSuccess {
cultnet_rs::CultNetMessage::SnapshotResponseRaw {
message_id: "completion".into(),
documents: Vec::new(),
}
} else {
cultnet_rs::CultNetMessage::Error {
error: "write_not_authorized: the catalog refused the document".into(),
code: Some(cultnet_rs::CultNetErrorCode::WriteNotAuthorized),
details: None,
}
},
CultNetWireContract::CultNetSchemaV0,
)?;
let options = CultNetRudpSendOptions {
reliable: false,
ordered: false,
sequenced: false,
now_ms: 2,
reliable_expire_after_ms: None,
};
let mut refusals = session.send_many("schema", refusal, options, Some(1200))?;
for refusal in &mut refusals {
refusal.ack = 0;
refusal.ack_mask = 0;
}
let send_refusals = |socket: &UdpSocket| -> Result<()> {
for refusal in &refusals {
socket.send_to(&encode_rudp_packet(refusal)?, peer)?;
}
Ok(())
};
if reply == CatalogReply::RefusalThenAck {
send_refusals(&socket)?;
thread::sleep(Duration::from_millis(100));
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
} else {
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
thread::sleep(Duration::from_millis(100));
send_refusals(&socket)?;
}
}
Ok(())
});
Ok((target, stop, catalog))
}
fn publish_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions {
CultMeshRudpDocumentPublishOptions {
target,
connect_timeout: Duration::from_secs(2),
flush_timeout: Duration::from_secs(3),
poll_interval: Duration::from_millis(2),
resend_delay_ms: 5,
..CultMeshRudpDocumentPublishOptions::default()
}
}
fn refused_note() -> cultnet_rs::CultNetMessage {
cultnet_rs::CultNetMessage::DocumentPutRaw {
message_id: "completion".into(),
document: cultnet_rs::CultNetRawDocumentRecord {
schema_id: "cultmesh.test.raw.v0".into(),
record_key: "tests:completion:a".into(),
stored_at: "2026-11-08T00:00:00Z".into(),
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
payload: vec![0xc0],
source_runtime_id: None,
source_agent_id: None,
source_role: None,
tags: None,
},
}
}
/// A catalog's refusal text is quoted only when it is one of the fixed /// A catalog's refusal text is quoted only when it is one of the fixed
/// refusals a CultMesh catalog sends. Any other text, which could carry /// refusals a CultMesh catalog sends. Any other text, which could carry
/// anything, stays out of the publisher's error. /// anything, stays out of the publisher's error.
@ -1899,4 +2044,63 @@ mod tests {
assert_eq!(lease.bytes(), &[1, 2, 3]); assert_eq!(lease.bytes(), &[1, 2, 3]);
Ok(()) Ok(())
} }
// Heimdall's invariant: no client treats a reliable-transport ack as write admission. The catalog here
// acknowledges the put and refuses it, the refusal arriving before or after the ack.
#[test]
fn a_publish_whose_refusal_arrives_before_the_ack_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::RefusalThenAck)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_publish_whose_ack_arrives_before_the_refusal_is_refused() -> Result<()> {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenRefusal)?;
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
let error = result.expect_err("a refused put is never reported as admitted");
assert!(error.to_string().contains("refused the put"), "{error:#}");
Ok(())
}
#[test]
fn a_batch_publish_is_not_complete_on_the_ack_of_refused_puts() -> Result<()> {
for reply in [CatalogReply::AckThenRefusal, CatalogReply::RefusalThenAck] {
let (target, stop, catalog) = acknowledging_refusing_catalog(reply)?;
let result = publish_cultnet_messages_to_rudp_catalog(&[refused_note()], publish_options(target));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
assert!(result.is_err(), "a refused put was reported as published");
}
Ok(())
}
#[test]
fn a_success_frame_completes_the_publish_without_waiting_out_the_result_window() -> Result<()> {
for batch in [false, true] {
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenSuccess)?;
let options = CultMeshRudpDocumentPublishOptions {
result_window: Duration::from_secs(30),
..publish_options(target)
};
let started = Instant::now();
let result = if batch {
publish_cultnet_messages_to_rudp_catalog(&[refused_note()], options)
} else {
publish_cultnet_message_to_rudp_catalog(&refused_note(), options)
};
stop.store(true, std::sync::atomic::Ordering::SeqCst);
catalog.join().expect("catalog thread should not panic")?;
result?;
assert!(started.elapsed() < Duration::from_secs(10), "batch={batch}: waited out the window");
}
Ok(())
}
} }

View file

@ -1,6 +1,6 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use cultnet_rs::{ use cultnet_rs::{
CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType,
CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract,
decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec,
encode_rudp_packet, is_permanent_send_error, send_error_code, encode_rudp_packet, is_permanent_send_error, send_error_code,
@ -19,6 +19,16 @@ const MAX_FRAGMENTS_PER_MESSAGE: usize = u16::MAX as usize;
/// can carry no shorter id, so no response to it can be smaller. /// can carry no shorter id, so no response to it can be smaller.
const SHORTEST_MESSAGE_ID: &str = "0"; const SHORTEST_MESSAGE_ID: &str = "0";
/// How many consecutive refused frames of one kind a session may send before it is ended: undecodable
/// frames (reset by any frame that decodes) and refused deletes (reset by any decoded non-delete).
/// Fewer are answered with a typed refusal and the session is kept; the cap that reaches this number
/// is answered too, then the session ends. The same number is the cap in every runtime.
pub const MAX_CONSECUTIVE_REFUSED_FRAMES: u32 = 8;
/// How long a session whose capped frame was answered waits for the peer to acknowledge the answer
/// before it ends anyway.
const CAPPED_ANSWER_GRACE_MILLIS: u64 = 2_000;
/// The transport identity of one remote CultNet RUDP session. /// The transport identity of one remote CultNet RUDP session.
/// ///
/// Connection identifiers are scoped to a remote socket address. Two clients /// Connection identifiers are scoped to a remote socket address. Two clients
@ -196,7 +206,10 @@ pub struct CultMeshRudpMaintenance {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CultMeshRudpApplicationOperation { pub enum CultMeshRudpApplicationOperation {
DocumentPutRaw, DocumentPutRaw,
DocumentDelete,
SnapshotRequest, SnapshotRequest,
/// A schema frame that did not decode as any CultNet message; there is no message id to name.
Unreadable,
} }
/// Why the server refused an application message. /// Why the server refused an application message.
@ -241,12 +254,18 @@ pub enum CultMeshRudpRejectionReason {
/// (`send_error_code`). The session has ended with a goodbye carrying the /// (`send_error_code`). The session has ended with a goodbye carrying the
/// same name, and no refusal: the server knows the peer cannot be reached. /// same name, and no refusal: the server knows the peer cannot be reached.
ResponseSendFailed(&'static str), ResponseSendFailed(&'static str),
/// The server has no delete path, so every delete is refused. Nothing reached the sink.
DeleteNotSupported,
/// A schema frame did not decode as a CultNet message. Carries the decoder's error text.
MessageUnreadable(String),
} }
impl std::fmt::Display for CultMeshRudpRejectionReason { impl std::fmt::Display for CultMeshRudpRejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error), Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error),
Self::DeleteNotSupported => f.write_str("the server does not take deletes"),
Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"),
Self::DocumentUnservable { Self::DocumentUnservable {
response_bytes, response_bytes,
max_snapshot_response_bytes, max_snapshot_response_bytes,
@ -287,7 +306,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason {
/// The texts a refused peer is sent, one per rejection variant. They are fixed: /// The texts a refused peer is sent, one per rejection variant. They are fixed:
/// a sink's or source's error text can quote what a peer sent, so it stays with /// a sink's or source's error text can quote what a peer sent, so it stays with
/// the caller that receives the rejection and never reaches the wire. /// the caller that receives the rejection and never reaches the wire.
pub(crate) const REFUSAL_TEXTS: [&str; 9] = [ pub(crate) const REFUSAL_TEXTS: [&str; 11] = [
"the catalog refused the document", "the catalog refused the document",
"the document can never be served", "the document can never be served",
"the snapshot source failed", "the snapshot source failed",
@ -297,6 +316,8 @@ pub(crate) const REFUSAL_TEXTS: [&str; 9] = [
"the retained payload budget is full", "the retained payload budget is full",
"the snapshot response could not be queued", "the snapshot response could not be queued",
"the snapshot response could not be sent", "the snapshot response could not be sent",
"the server does not take deletes",
"the message could not be read",
]; ];
impl CultMeshRudpRejectionReason { impl CultMeshRudpRejectionReason {
@ -312,18 +333,77 @@ impl CultMeshRudpRejectionReason {
Self::PayloadBudgetFull => 6, Self::PayloadBudgetFull => 6,
Self::ResponseQueueFailed(_) => 7, Self::ResponseQueueFailed(_) => 7,
Self::ResponseSendFailed(_) => 8, Self::ResponseSendFailed(_) => 8,
Self::DeleteNotSupported => 9,
Self::MessageUnreadable(_) => 10,
}] }]
} }
} }
/// The typed `write_not_authorized` refusal for a put whose record is `record_key`, as C#
/// `CultNetErrorMessage.ForRemoteWriteRefused` builds it: code, and `details` naming the record.
pub(crate) fn write_refused_message(record_key: &str, reason: &str) -> CultNetMessage {
CultNetMessage::Error {
error: format!("write_not_authorized: {reason}"),
code: Some(CultNetErrorCode::WriteNotAuthorized),
details: Some(CultNetErrorDetails {
field: Some("recordKey".into()),
value: Some(record_key.into()),
as_of: None,
current: None,
}),
}
}
/// The typed `message_invalid` refusal naming the request `field` at fault, as C#
/// `CultNetErrorMessage.ForMessageInvalid` builds it.
pub(crate) fn message_invalid_message(field: &str, reason: &str) -> CultNetMessage {
CultNetMessage::Error {
error: format!("message_invalid: {reason}"),
code: Some(CultNetErrorCode::MessageInvalid),
details: Some(CultNetErrorDetails {
field: Some(field.into()),
value: None,
as_of: None,
current: None,
}),
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CultMeshRudpApplicationRejection { pub struct CultMeshRudpApplicationRejection {
pub session: CultMeshRudpSessionKey, pub session: CultMeshRudpSessionKey,
pub operation: CultMeshRudpApplicationOperation, pub operation: CultMeshRudpApplicationOperation,
pub message_id: String, pub message_id: String,
/// The record a rejected put or delete concerned; `None` for a snapshot request or an unreadable frame.
/// A sink or delete refusal names it to the peer.
pub record_key: Option<String>,
pub reason: CultMeshRudpRejectionReason, pub reason: CultMeshRudpRejectionReason,
} }
impl CultMeshRudpApplicationRejection {
/// What the refused peer is sent: a refused put or delete is a typed `write_not_authorized` naming
/// the record, an unreadable frame a typed `message_invalid` naming `message`; every other rejection
/// is the variant's fixed text, untyped. None carries the sink's, source's or decoder's own text.
fn refusal_message(&self) -> CultNetMessage {
let text = self.reason.refusal_text();
match (&self.reason, &self.record_key) {
(
CultMeshRudpRejectionReason::SinkRefused(_)
| CultMeshRudpRejectionReason::DeleteNotSupported,
Some(record_key),
) => write_refused_message(record_key, text),
(CultMeshRudpRejectionReason::MessageUnreadable(_), _) => {
message_invalid_message("message", text)
}
_ => CultNetMessage::Error {
error: text.into(),
code: None,
details: None,
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum CultMeshRudpPollOutcome { pub enum CultMeshRudpPollOutcome {
Idle, Idle,
@ -335,8 +415,8 @@ pub enum CultMeshRudpPollOutcome {
enum SessionEnd { enum SessionEnd {
/// The session refused a packet. /// The session refused a packet.
Refused, Refused,
/// The server rejected a peer's message; the peer is sent this fixed text. /// The server rejected a peer's message; the peer is sent this refusal.
Rejected(&'static str), Rejected(CultNetMessage),
/// The session owes its peer a packet that can never be sent as built. /// The session owes its peer a packet that can never be sent as built.
Unsendable(std::io::Error), Unsendable(std::io::Error),
} }
@ -346,6 +426,11 @@ struct SessionEntry {
created_at_monotonic_millis: u64, created_at_monotonic_millis: u64,
last_activity_monotonic_millis: u64, last_activity_monotonic_millis: u64,
admitted_payload_bytes: usize, admitted_payload_bytes: usize,
undecodable_frames_in_a_row: u32,
refused_deletes_in_a_row: u32,
/// Set once the peer's capped frame has been answered: the session ends when the answer is acknowledged,
/// or at this time.
ending_deadline_monotonic_millis: Option<u64>,
} }
/// A synchronous, multi-session CultNet RUDP raw-document server. /// A synchronous, multi-session CultNet RUDP raw-document server.
@ -520,7 +605,18 @@ where
}; };
let mut unsendable = match result.reply { let mut unsendable = match result.reply {
Some(reply) => self.send_packet(key.remote_addr, &reply)?, Some(mut reply) => {
// A reply (a Pong) in the grace after the capped frame acknowledges nothing either.
if self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
{
reply.ack = 0;
reply.ack_mask = 0;
}
self.send_packet(key.remote_addr, &reply)?
}
None => None, None => None,
}; };
@ -529,24 +625,125 @@ where
return Ok(CultMeshRudpPollOutcome::Handled); return Ok(CultMeshRudpPollOutcome::Handled);
} }
let mut kept_rejection = None;
for frame in result.delivered { for frame in result.delivered {
if frame.channel_id != "schema" { if frame.channel_id != "schema" {
continue; continue;
} }
let message = match decode_cultnet_message_from_slice( // The capped frame was answered: nothing after it is served while the answer is delivered.
if self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
{
break;
}
let decoded = decode_cultnet_message_from_slice(
&frame.payload, &frame.payload,
CultNetWireContract::CultNetSchemaV0, CultNetWireContract::CultNetSchemaV0,
) { );
Ok(message) => message, // Runs of refusals this frame extends or breaks. A session is kept through a short run and
Err(_) => continue, // ended by `MAX_CONSECUTIVE_REFUSED_FRAMES` in a row.
let (is_unreadable, is_delete) = (
decoded.is_err(),
matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })),
);
let run = self.sessions.get_mut(&key).map(|entry| {
entry.undecodable_frames_in_a_row = if is_unreadable {
entry.undecodable_frames_in_a_row + 1
} else {
0
};
if is_delete {
entry.refused_deletes_in_a_row += 1;
} else if !is_unreadable {
entry.refused_deletes_in_a_row = 0;
}
if is_unreadable {
entry.undecodable_frames_in_a_row
} else {
entry.refused_deletes_in_a_row
}
});
let rejection = match decoded {
Ok(message) => self.deliver_application_message(
key,
frame.sequence,
message,
now_unix,
now_monotonic,
)?,
Err(error) => Some(CultMeshRudpApplicationRejection {
session: key,
operation: CultMeshRudpApplicationOperation::Unreadable,
message_id: String::new(),
record_key: None,
reason: CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")),
}),
}; };
if let Some(rejection) = self.deliver_application_message( if let Some(rejection) = rejection {
key, // A refused delete or unreadable frame below the cap is answered on the live session,
frame.sequence, // which goes on: the peer keeps the connection a stray frame did not break.
message, if (is_unreadable || is_delete)
now_unix, && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES)
now_monotonic, {
)? { let refusal = rejection.refusal_message();
let packets = match self.sessions.remove(&key) {
Some(mut entry) => {
let packets = self.refusal_packets(&mut entry.session, &refusal, false);
self.sessions.insert(key, entry);
packets
}
None => Vec::new(),
};
for packet in &packets {
if let Some(error) = self.send_packet(key.remote_addr, packet)? {
unsendable = Some(error);
break;
}
}
kept_rejection = Some(rejection);
continue;
}
// The capped frame is answered reliably, so a lost datagram is resent; the session ends once
// the peer has acknowledged the answer or the grace runs out (`end_answered_sessions`). When
// the answer cannot be queued, the session ends at once with the refusal as before.
if (is_unreadable || is_delete)
&& run.is_some()
&& !matches!(
rejection.reason,
CultMeshRudpRejectionReason::ResponseSendFailed(_)
)
{
let refusal = rejection.refusal_message();
let packets = match self.sessions.remove(&key) {
Some(mut entry) => {
let packets = self.refusal_packets(&mut entry.session, &refusal, true);
if !packets.is_empty() {
entry.ending_deadline_monotonic_millis =
Some(now_monotonic.saturating_add(CAPPED_ANSWER_GRACE_MILLIS));
}
self.sessions.insert(key, entry);
packets
}
None => Vec::new(),
};
if packets.is_empty() {
self.end_session(key, SessionEnd::Rejected(refusal))?;
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
}
for mut packet in packets {
// Acknowledges nothing, as every refusal: the refused frame was not admitted.
packet.ack = 0;
packet.ack_mask = 0;
if let Some(error) = self.send_packet(key.remote_addr, &packet)? {
unsendable = Some(error);
break;
}
}
kept_rejection = Some(rejection);
break;
}
// The only reply `receive` returns is a Pong, which delivers no // The only reply `receive` returns is a Pong, which delivers no
// frame, so no earlier send of this poll can precede a rejection. // frame, so no earlier send of this poll can precede a rejection.
// A response that could not be sent ended its session where the // A response that could not be sent ended its session where the
@ -555,13 +752,17 @@ where
rejection.reason, rejection.reason,
CultMeshRudpRejectionReason::ResponseSendFailed(_) CultMeshRudpRejectionReason::ResponseSendFailed(_)
) { ) {
self.end_session(key, SessionEnd::Rejected(rejection.reason.refusal_text()))?; self.end_session(key, SessionEnd::Rejected(rejection.refusal_message()))?;
} }
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
} }
} }
if packet.reliable && unsendable.is_none() { let answering_capped_frame = self
.sessions
.get(&key)
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some());
if packet.reliable && unsendable.is_none() && !answering_capped_frame {
let ack = self let ack = self
.sessions .sessions
.get_mut(&key) .get_mut(&key)
@ -573,7 +774,11 @@ where
if let Some(error) = unsendable { if let Some(error) = unsendable {
self.end_session(key, SessionEnd::Unsendable(error))?; self.end_session(key, SessionEnd::Unsendable(error))?;
} }
Ok(CultMeshRudpPollOutcome::Handled) self.end_answered_sessions()?;
Ok(match kept_rejection {
Some(rejection) => CultMeshRudpPollOutcome::ApplicationRejected(rejection),
None => CultMeshRudpPollOutcome::Handled,
})
} }
/// Expire idle sessions and resend reliable packets whose deadline passed. /// Expire idle sessions and resend reliable packets whose deadline passed.
@ -587,10 +792,16 @@ where
&& now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms
}); });
let expired = before - self.sessions.len(); let expired = before - self.sessions.len();
self.end_answered_sessions()?;
let mut resends = Vec::new(); let mut resends = Vec::new();
for (key, entry) in &mut self.sessions { for (key, entry) in &mut self.sessions {
for packet in entry.session.due_resends(now) { for mut packet in entry.session.due_resends(now) {
if entry.ending_deadline_monotonic_millis.is_some() {
// The resent answer to a capped frame acknowledges nothing either.
packet.ack = 0;
packet.ack_mask = 0;
}
resends.push((*key, packet)); resends.push((*key, packet));
} }
} }
@ -609,6 +820,26 @@ where
}) })
} }
/// Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
/// answer is resent first), or when the grace has run out.
fn end_answered_sessions(&mut self) -> Result<()> {
let now = self.clock.now_monotonic_millis();
let due: Vec<CultMeshRudpSessionKey> = self
.sessions
.iter()
.filter(|(_, entry)| {
entry.ending_deadline_monotonic_millis.is_some_and(|deadline| {
entry.session.outstanding_reliable_packet_count() == 0 || now >= deadline
})
})
.map(|(key, _)| *key)
.collect();
for key in due {
self.end_session(key, SessionEnd::Refused)?;
}
Ok(())
}
/// `Ok(false)` when the Connect was refused because the session cap is full; /// `Ok(false)` when the Connect was refused because the session cap is full;
/// only a socket failure is an `Err`. /// only a socket failure is an `Err`.
fn accept_connection( fn accept_connection(
@ -658,6 +889,9 @@ where
created_at_monotonic_millis: now, created_at_monotonic_millis: now,
last_activity_monotonic_millis: now, last_activity_monotonic_millis: now,
admitted_payload_bytes: 0, admitted_payload_bytes: 0,
undecodable_frames_in_a_row: 0,
refused_deletes_in_a_row: 0,
ending_deadline_monotonic_millis: None,
}, },
); );
accept accept
@ -683,11 +917,13 @@ where
message_id, message_id,
document, document,
} => { } => {
let record_key = document.record_key.clone();
let reject = |reason| { let reject = |reason| {
Ok(Some(CultMeshRudpApplicationRejection { Ok(Some(CultMeshRudpApplicationRejection {
session: key, session: key,
operation: CultMeshRudpApplicationOperation::DocumentPutRaw, operation: CultMeshRudpApplicationOperation::DocumentPutRaw,
message_id: message_id.clone(), message_id: message_id.clone(),
record_key: Some(record_key.clone()),
reason, reason,
})) }))
}; };
@ -758,6 +994,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!( reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!(
"{error:#}" "{error:#}"
)), )),
@ -769,6 +1006,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments { reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments {
documents: documents.len(), documents: documents.len(),
max_snapshot_documents: self.options.max_snapshot_documents, max_snapshot_documents: self.options.max_snapshot_documents,
@ -786,6 +1024,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error), reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error),
})); }));
} }
@ -795,6 +1034,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge { reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge {
response_bytes: payload.len(), response_bytes: payload.len(),
max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, max_snapshot_response_bytes: self.options.max_snapshot_response_bytes,
@ -806,6 +1046,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::PayloadBudgetFull, reason: CultMeshRudpRejectionReason::PayloadBudgetFull,
})); }));
} }
@ -832,6 +1073,7 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!( reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!(
"{error:#}" "{error:#}"
)), )),
@ -848,11 +1090,25 @@ where
session: key, session: key,
operation: CultMeshRudpApplicationOperation::SnapshotRequest, operation: CultMeshRudpApplicationOperation::SnapshotRequest,
message_id, message_id,
record_key: None,
reason: CultMeshRudpRejectionReason::ResponseSendFailed(code), reason: CultMeshRudpRejectionReason::ResponseSendFailed(code),
})); }));
} }
} }
} }
CultNetMessage::DocumentDelete {
message_id,
record_key,
..
} => {
return Ok(Some(CultMeshRudpApplicationRejection {
session: key,
operation: CultMeshRudpApplicationOperation::DocumentDelete,
message_id,
record_key: Some(record_key),
reason: CultMeshRudpRejectionReason::DeleteNotSupported,
}));
}
_ => {} _ => {}
} }
Ok(None) Ok(None)
@ -875,7 +1131,7 @@ where
}; };
let (refusal, mut unsendable) = match end { let (refusal, mut unsendable) = match end {
SessionEnd::Refused => (Vec::new(), None), SessionEnd::Refused => (Vec::new(), None),
SessionEnd::Rejected(text) => (self.refusal_packets(&mut entry.session, text), None), SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal, false), None),
SessionEnd::Unsendable(error) => (Vec::new(), Some(error)), SessionEnd::Unsendable(error) => (Vec::new(), Some(error)),
}; };
entry.session.reset_peer_state(); entry.session.reset_peer_state();
@ -898,25 +1154,21 @@ where
Ok(()) Ok(())
} }
/// The refusal a rejected peer is sent, as unreliable, unordered packets. /// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response.
fn refusal_packets( fn refusal_packets(
&self, &self,
session: &mut CultNetRudpSession, session: &mut CultNetRudpSession,
text: &str, refusal: &CultNetMessage,
reliable: bool,
) -> Vec<CultNetRudpPacket> { ) -> Vec<CultNetRudpPacket> {
let refusal = CultNetMessage::Error { encode_cultnet_message_to_vec(refusal, CultNetWireContract::CultNetSchemaV0)
error: text.into(),
code: None,
details: None,
};
encode_cultnet_message_to_vec(&refusal, CultNetWireContract::CultNetSchemaV0)
.and_then(|payload| { .and_then(|payload| {
session.send_many( session.send_many(
"schema", "schema",
payload, payload,
CultNetRudpSendOptions { CultNetRudpSendOptions {
reliable: false, reliable,
ordered: false, ordered: reliable,
sequenced: false, sequenced: false,
now_ms: self.clock.now_monotonic_millis(), now_ms: self.clock.now_monotonic_millis(),
reliable_expire_after_ms: None, reliable_expire_after_ms: None,
@ -1080,3 +1332,39 @@ fn encode_snapshot_response(response: &CultNetMessage) -> std::result::Result<Ve
fn duration_millis(duration: Duration) -> u64 { fn duration_millis(duration: Duration) -> u64 {
duration.as_millis().try_into().unwrap_or(u64::MAX) duration.as_millis().try_into().unwrap_or(u64::MAX)
} }
#[cfg(test)]
mod tests {
use super::*;
// contracts/cultnet/interop/error-vectors.json, `write_not_authorized`: bytes captured from C# MessagePack
// (`ForRemoteWriteRefused(request for "some:key", "not granted")`) and pinned by C#, TS and Python too.
const WRITE_NOT_AUTHORIZED_HEX: &str = "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0";
// The same file's `message_invalid` vector: field `document`, reason "Raw put messages must contain a document map."
const MESSAGE_INVALID_HEX: &str = "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0";
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
}
#[test]
fn the_sink_refusal_is_the_pinned_write_not_authorized_bytes() {
let bytes = encode_cultnet_message_to_vec(
&write_refused_message("some:key", "not granted"),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
assert_eq!(hex(&bytes), WRITE_NOT_AUTHORIZED_HEX);
}
#[test]
fn the_unreadable_refusal_is_the_pinned_message_invalid_bytes() {
let bytes = encode_cultnet_message_to_vec(
&message_invalid_message("document", "Raw put messages must contain a document map."),
CultNetWireContract::CultNetSchemaV0,
)
.unwrap();
assert_eq!(hex(&bytes), MESSAGE_INVALID_HEX);
}
}

View file

@ -0,0 +1,261 @@
//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust.
//!
//! `authority-route-vectors.json` is written by the C# reference (its signatures
//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript
//! over awkward inputs. Each negative case fails when the matching check is
//! removed from `cultmesh_rs::authority`.
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use cultmesh_rs::authority::*;
use p256::ecdsa::Signature;
use serde_json::Value;
use std::path::PathBuf;
struct Vector {
now: i64,
root: P256PublicKey,
verse_id: String,
route: AuthorityRoute,
request: SessionOpen,
accepted: SessionAccepted,
trust: AuthorityTrustPolicy,
}
fn s<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_else(|| panic!("missing string {key}"))
}
fn key(value: &Value) -> P256PublicKey {
P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap()
}
fn load(name: &str) -> Vector {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../contracts/cultmesh")
.join(name);
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let r = &doc["route"];
let c = &r["certificate"];
let certificate = RouteCertificate::new(
key(&c["providerKey"]),
s(c, "odinKeyId"),
c["issuedAtUnixMilliseconds"].as_i64().unwrap(),
c["expiresAtUnixMilliseconds"].as_i64().unwrap(),
s(c, "signature"),
)
.unwrap();
let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
let route = AuthorityRoute::new(
s(r, "authorityRuntimeId"),
s(r, "endpoint"),
&protocols,
r["priority"].as_i64().unwrap() as i32,
Some(s(r, "generation")),
Some(certificate),
)
.unwrap();
let p = &doc["sessionProof"];
let q = &p["request"];
let root = key(&doc["odinRoot"]);
Vector {
now: doc["nowUnixMilliseconds"].as_i64().unwrap(),
verse_id: s(r, "verseId").to_owned(),
route,
request: SessionOpen {
client_nonce: s(q, "clientNonce").into(),
message_id: s(q, "messageId").into(),
source_runtime_id: s(q, "sourceRuntimeId").into(),
verse_id: s(q, "verseId").into(),
authority_runtime_id: s(q, "authorityRuntimeId").into(),
protocol_id: s(q, "protocolId").into(),
route_generation: s(q, "routeGeneration").into(),
},
accepted: SessionAccepted {
client_nonce: s(q, "clientNonce").into(),
provider_key_id: s(p, "providerKeyId").into(),
provider_signature: s(p, "providerSignature").into(),
},
trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(),
root,
}
}
impl Vector {
fn certificate(&self) -> &RouteCertificate {
self.route.certificate.as_ref().unwrap()
}
fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute {
let mut route = self.route.clone();
edit(route.certificate.as_mut().unwrap());
route
}
fn refusal(&self, route: &AuthorityRoute, now: i64) -> String {
let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused");
assert_eq!(AuthorityRouteRefusal::REASON, "authentication");
assert_eq!(route.endpoint, error.endpoint);
error.message
}
fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool {
verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap()
}
}
const INVALID: &str = "The Odin route certificate signature is invalid.";
#[test]
fn cs_written_vector_verifies_route_and_session_proof() {
let v = load("authority-route-vectors.json");
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
assert!(v.proof(&v.request, &v.accepted));
}
#[test]
fn ts_signed_vector_verifies_and_tamper_is_refused() {
let v = load("authority-route-vectors.ts-signed.json");
v.trust.validate(&v.verse_id, &v.route, v.now).unwrap();
assert!(v.proof(&v.request, &v.accepted));
let mut tampered = v.route.clone();
tampered.priority += 1;
assert_eq!(INVALID, v.refusal(&tampered, v.now));
}
#[test]
fn canonical_route_is_length_prefixed_and_sorted() {
let v = load("authority-route-vectors.json");
let bytes = canonical_route(&v.verse_id, &v.route).unwrap();
assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria"));
let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1";
assert!(bytes.windows(needle.len()).any(|w| w == needle));
}
#[test]
fn tampered_payload_fields_are_refused() {
let v = load("authority-route-vectors.json");
let edits: Vec<Box<dyn Fn(&mut AuthorityRoute)>> = vec![
Box::new(|r| r.priority = 8),
Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()),
Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]),
Box::new(|r| r.generation = "other".into()),
Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1),
];
for edit in edits {
let mut route = v.route.clone();
edit(&mut route);
assert_eq!(INVALID, v.refusal(&route, v.now));
}
let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err();
assert_eq!(INVALID, error.message);
}
#[test]
fn wrong_root_key_is_refused() {
let v = load("authority-route-vectors.json");
// The provider's key is a real P-256 key that did not sign this route.
let provider = &v.certificate().provider_key;
let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap();
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap();
assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message);
}
#[test]
fn untrusted_odin_key_id_is_refused() {
let v = load("authority-route-vectors.json");
let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap();
let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap();
assert_eq!(
"Odin key 'odin-vector' is not trusted by this consumer.",
trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message
);
}
#[test]
fn expired_and_not_yet_valid_are_refused() {
let v = load("authority-route-vectors.json");
let window = "The Odin route certificate is not currently valid.";
let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds);
v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap();
v.trust.validate(&v.verse_id, &v.route, issued).unwrap();
assert_eq!(window, v.refusal(&v.route, expires));
assert_eq!(window, v.refusal(&v.route, issued - 1));
}
#[test]
fn unsigned_and_unprotected_routes_are_refused() {
let v = load("authority-route-vectors.json");
let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate.";
let mut unsigned = v.route.clone();
unsigned.certificate = None;
assert_eq!(unsigned_message, v.refusal(&unsigned, v.now));
let blank = v.with_certificate(|c| c.signature = " ".into());
assert_eq!(unsigned_message, v.refusal(&blank, v.now));
let mut plain = v.route.clone();
plain.endpoint = "ws://provider.example/mesh".into();
assert_eq!(
"Authenticated remote CultMesh routes require TLS or QUIC channel protection.",
v.refusal(&plain, v.now)
);
}
#[test]
fn local_development_admits_only_unsigned_loopback() {
let v = load("authority-route-vectors.json");
let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap();
let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap();
let loopback = route("ws://127.0.0.1:9/mesh");
dev.validate(&v.verse_id, &loopback, v.now).unwrap();
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap());
for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] {
assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}");
}
let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap();
assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err());
}
#[test]
fn malformed_signatures_get_their_own_refusals() {
let v = load("authority-route-vectors.json");
let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into());
assert_eq!("The Odin route signature is not base64.", v.refusal(&not_base64, v.now));
let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63]));
assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now));
}
#[test]
fn high_s_signature_verifies_as_it_does_in_dotnet() {
let v = load("authority-route-vectors.json");
let raw = STANDARD.decode(&v.certificate().signature).unwrap();
let signature = Signature::from_slice(&raw).unwrap();
let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap();
let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes()));
v.trust.validate(&v.verse_id, &high, v.now).unwrap();
}
#[test]
fn session_proof_rejects_replay_wrong_key_and_forgery() {
let v = load("authority-route-vectors.json");
let mut replayed = v.request.clone();
replayed.client_nonce = "replayed".into();
let mut accepted = v.accepted.clone();
accepted.client_nonce = "replayed".into();
assert!(!v.proof(&replayed, &accepted));
let mut wrong_id = v.accepted.clone();
wrong_id.provider_key_id = "other".into();
assert!(!v.proof(&v.request, &wrong_id));
let mut wrong_nonce = v.accepted.clone();
wrong_nonce.client_nonce = "other".into();
assert!(!v.proof(&v.request, &wrong_nonce));
let mut empty = v.accepted.clone();
empty.provider_signature = String::new();
assert!(!v.proof(&v.request, &empty));
let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap();
raw[0] ^= 1;
let mut forged = v.accepted.clone();
forged.provider_signature = STANDARD.encode(raw);
assert!(!v.proof(&v.request, &forged));
let mut moved = v.request.clone();
moved.protocol_id = "cultmesh.content.v1".into();
assert!(!v.proof(&moved, &v.accepted));
let expires = v.certificate().expires_at_unix_milliseconds;
assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err());
}

View file

@ -6,7 +6,7 @@ use cultmesh_rs::{
CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource, CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource,
}; };
use cultnet_rs::{ use cultnet_rs::{
CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding,
CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode, CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode,
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
@ -343,6 +343,18 @@ fn refusal_seen_by(
client: &mut CultNetRudpSocketTransportConnection, client: &mut CultNetRudpSocketTransportConnection,
receipt: &CultNetRudpReliableSendReceipt, receipt: &CultNetRudpReliableSendReceipt,
) -> Result<String> { ) -> Result<String> {
let (error, code, details) = typed_refusal_seen_by(client, receipt, None)?;
assert_eq!((code, details), (None, None));
Ok(error)
}
/// `server` is polled while the goodbye is awaited when the refusal ends its session only once the peer has
/// acknowledged it (the capped frame's answer).
fn typed_refusal_seen_by(
client: &mut CultNetRudpSocketTransportConnection,
receipt: &CultNetRudpReliableSendReceipt,
mut server: Option<&mut Server>,
) -> Result<(String, Option<CultNetErrorCode>, Option<CultNetErrorDetails>)> {
let mut refusal = None; let mut refusal = None;
for _ in 0..2_000 { for _ in 0..2_000 {
if let Some(frame) = client.receive_once()? { if let Some(frame) = client.receive_once()? {
@ -362,7 +374,6 @@ fn refusal_seen_by(
else { else {
panic!("the refused peer was sent no refusal: {refusal:?}"); panic!("the refused peer was sent no refusal: {refusal:?}");
}; };
assert_eq!((code, details), (None, None));
assert!( assert!(
!error.contains("CANARY"), !error.contains("CANARY"),
"a sink's or source's text reached the peer: {error}" "a sink's or source's text reached the peer: {error}"
@ -373,6 +384,9 @@ fn refusal_seen_by(
"a refusal must not acknowledge the refused message" "a refusal must not acknowledge the refused message"
); );
for _ in 0..2_000 { for _ in 0..2_000 {
if let Some(server) = server.as_deref_mut() {
server.poll_once()?;
}
client.receive_once()?; client.receive_once()?;
if !client.connected() { if !client.connected() {
break; break;
@ -384,7 +398,7 @@ fn refusal_seen_by(
client.reliable_send_status(receipt), client.reliable_send_status(receipt),
CultNetRudpReliableSendStatus::Invalidated CultNetRudpReliableSendStatus::Invalidated
); );
Ok(error) Ok((error, code, details))
} }
/// Reads every datagram waiting at the server, such as a refused peer's /// Reads every datagram waiting at the server, such as a refused peer's
@ -671,10 +685,21 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno
); );
assert_eq!(server.session_count(), 2); assert_eq!(server.session_count(), 2);
// The caller keeps the sink's text; the peer is sent fixed text, never it. // The caller keeps the sink's text; the peer is sent fixed text, never it.
// A refused put is typed write_not_authorized and names the record; nothing of it reached the sink.
assert_eq!( assert_eq!(
refusal_seen_by(&mut publisher, &publish_receipt)?, typed_refusal_seen_by(&mut publisher, &publish_receipt, None)?,
"the catalog refused the document" (
"write_not_authorized: the catalog refused the document".to_string(),
Some(CultNetErrorCode::WriteNotAuthorized),
Some(CultNetErrorDetails {
field: Some("recordKey".into()),
value: Some(document("rejected", vec![0, 255, 1]).record_key),
as_of: None,
current: None,
})
)
); );
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "a refused put changes nothing");
drain_until_idle(&mut server)?; drain_until_idle(&mut server)?;
let snapshot_receipt = send_reliable( let snapshot_receipt = send_reliable(
@ -732,6 +757,173 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno
Ok(()) Ok(())
} }
/// Sends `payload` reliably, polls until the server refuses it, and returns the typed refusal the
/// peer is sent. The session is not asserted here: the caller says whether it lives.
fn refusal_on_live_session(
server: &mut CultMeshRudpDocumentServer<Sink, Source, Clock>,
client: &mut CultNetRudpSocketTransportConnection,
payload: Vec<u8>,
) -> Result<(Option<CultNetErrorCode>, Option<CultNetErrorDetails>, String)> {
client.send_reliable("schema", payload)?;
let mut rejected = false;
for _ in 0..2_000 {
if !rejected {
rejected = matches!(server.poll_once()?, CultMeshRudpPollOutcome::ApplicationRejected(_));
}
if let Some(frame) = client.receive_once()? {
if let CultNetMessage::Error { error, code, details } = decode_cultnet_message_from_slice(
&frame.payload,
CultNetWireContract::CultNetSchemaV0,
)? {
assert!(rejected, "the refusal preceded the server's own outcome");
return Ok((code, details, error));
}
}
thread::sleep(Duration::from_millis(1));
}
anyhow::bail!("no typed refusal reached the peer")
}
fn garbage() -> Vec<u8> {
let mut payload = vec![0xAE];
payload.extend_from_slice(b"CANARY-garbage");
payload
}
fn delete_of(key: &str) -> Vec<u8> {
encode_cultnet_message_to_vec(
&CultNetMessage::DocumentDelete {
message_id: "del".into(),
schema_id: "sha256:note".into(),
record_key: key.into(),
},
CultNetWireContract::CultNetSchemaV0,
)
.unwrap()
}
fn valid_put(key: &str) -> Vec<u8> {
encode_cultnet_message_to_vec(
&CultNetMessage::DocumentPutRaw {
message_id: format!("put-{key}"),
document: document(key, vec![1, 2, 3]),
},
CultNetWireContract::CultNetSchemaV0,
)
.unwrap()
}
fn stored_after_poll(
server: &mut CultMeshRudpDocumentServer<Sink, Source, Clock>,
client: &mut CultNetRudpSocketTransportConnection,
sink: &Sink,
receipts: usize,
) -> Result<()> {
client.send_reliable("schema", valid_put(&format!("k{receipts}")))?;
for _ in 0..2_000 {
server.poll_once()?;
client.receive_once()?;
if sink.0.lock().unwrap().receipts.len() >= receipts {
return Ok(());
}
thread::sleep(Duration::from_millis(1));
}
anyhow::bail!("a valid put on the kept session was not stored")
}
#[test]
fn an_unreadable_frame_is_refused_typed_and_the_session_kept_until_eight_in_a_row() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(49_000), sink.clone(), Source::default())?;
let target = server.local_addr()?;
let mut garbler = client(target, 111)?;
let mut survivor = client(target, 113)?;
connect(&mut server, &mut [&mut garbler, &mut survivor])?;
let message_invalid = (
Some(CultNetErrorCode::MessageInvalid),
Some(CultNetErrorDetails {
field: Some("message".into()),
value: None,
as_of: None,
current: None,
}),
"message_invalid: the message could not be read".to_string(),
);
// Seven in a row are answered and the session lives, a valid frame resets the run, and seven more
// are answered again: a counter that never resets, or a cap below eight, ends the session early.
for _ in 0..7 {
assert_eq!(refusal_on_live_session(&mut server, &mut garbler, garbage())?, message_invalid);
assert!(garbler.connected());
}
stored_after_poll(&mut server, &mut garbler, &sink, 1)?;
for _ in 0..7 {
assert_eq!(refusal_on_live_session(&mut server, &mut garbler, garbage())?, message_invalid);
assert!(garbler.connected());
}
assert_eq!(server.session_count(), 2);
// The eighth consecutive one is answered too, then the session ends with a goodbye.
let receipt = garbler.send_reliable("schema", garbage())?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("the eighth consecutive unreadable frame must be refused");
};
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::Unreadable);
let (error, code, _) = typed_refusal_seen_by(&mut garbler, &receipt, Some(&mut server))?;
assert_eq!((error.as_str(), code), (message_invalid.2.as_str(), message_invalid.0));
drain_until_idle(&mut server)?;
assert_eq!(server.session_count(), 1, "only the capped peer's session ended");
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
Ok(())
}
#[test]
fn a_refused_delete_keeps_the_session_until_eight_in_a_row() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(50_000), sink.clone(), Source::default())?;
let target = server.local_addr()?;
let mut deleter = client(target, 112)?;
connect(&mut server, &mut [&mut deleter])?;
let refused = |key: &str| {
(
Some(CultNetErrorCode::WriteNotAuthorized),
Some(CultNetErrorDetails {
field: Some("recordKey".into()),
value: Some(key.into()),
as_of: None,
current: None,
}),
"write_not_authorized: the server does not take deletes".to_string(),
)
};
for _ in 0..7 {
assert_eq!(refusal_on_live_session(&mut server, &mut deleter, delete_of("note:victim"))?, refused("note:victim"));
assert!(deleter.connected());
}
// A decoded non-delete frame resets the run.
stored_after_poll(&mut server, &mut deleter, &sink, 1)?;
for _ in 0..7 {
assert_eq!(refusal_on_live_session(&mut server, &mut deleter, delete_of("note:victim"))?, refused("note:victim"));
}
let receipt = deleter.send_reliable("schema", delete_of("note:victim"))?;
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
panic!("the eighth consecutive delete must be refused");
};
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::DocumentDelete);
assert_eq!(rejection.reason, CultMeshRudpRejectionReason::DeleteNotSupported);
let (error, code, details) = typed_refusal_seen_by(&mut deleter, &receipt, Some(&mut server))?;
assert_eq!(
(error, code, details),
{
let (c, d, e) = refused("note:victim");
(e, c, d)
}
);
drain_until_idle(&mut server)?;
assert_eq!(server.session_count(), 0);
Ok(())
}
#[test] #[test]
fn snapshot_response_resends_until_acknowledged() -> Result<()> { fn snapshot_response_resends_until_acknowledged() -> Result<()> {
let clock = Clock::new(51_000); let clock = Clock::new(51_000);
@ -1437,6 +1629,11 @@ impl RawPeer {
/// Sends `message` reliably and in order; returns its sequences. /// Sends `message` reliably and in order; returns its sequences.
fn send(&mut self, message: &CultNetMessage) -> Result<Vec<u32>> { fn send(&mut self, message: &CultNetMessage) -> Result<Vec<u32>> {
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
self.send_payload(payload)
}
/// Sends `payload` as one reliable, ordered schema frame; returns its sequences.
fn send_payload(&mut self, payload: Vec<u8>) -> Result<Vec<u32>> {
let options = cultnet_rs::CultNetRudpSendOptions { let options = cultnet_rs::CultNetRudpSendOptions {
reliable: true, reliable: true,
ordered: true, ordered: true,
@ -1525,9 +1722,14 @@ fn a_refusal_reaches_a_peer_missing_an_earlier_packet() -> Result<()> {
assert_eq!( assert_eq!(
refusals, refusals,
vec![CultNetMessage::Error { vec![CultNetMessage::Error {
error: "the catalog refused the document".into(), error: "write_not_authorized: the catalog refused the document".into(),
code: None, code: Some(CultNetErrorCode::WriteNotAuthorized),
details: None, details: Some(CultNetErrorDetails {
field: Some("recordKey".into()),
value: Some(document("refused", vec![4, 5, 6]).record_key),
as_of: None,
current: None,
}),
}] }]
); );
assert!(disconnected, "the refusal is followed by a goodbye"); assert!(disconnected, "the refusal is followed by a goodbye");
@ -1719,3 +1921,138 @@ fn a_response_that_is_too_large_to_send_is_named_emsgsize() -> Result<()> {
); );
Ok(()) Ok(())
} }
/// A peer on `server` that has sent seven unreadable frames, each answered and processed, and then the
/// eighth, whose answer is waiting at its socket. Returns the peer and the eighth frame's sequence.
fn peer_at_the_cap(server: &mut Server, connection_id: u32) -> Result<(RawPeer, u32)> {
let mut peer = RawPeer::connect(server, connection_id)?;
for _ in 0..7 {
peer.send_payload(garbage())?;
assert!(matches!(
server.poll_once()?,
CultMeshRudpPollOutcome::ApplicationRejected(_)
));
for packet in peer.drain() {
peer.session.receive(&packet, 3)?;
}
}
let capped = peer.send_payload(garbage())?[0];
assert!(matches!(
server.poll_once()?,
CultMeshRudpPollOutcome::ApplicationRejected(_)
));
Ok((peer, capped))
}
fn is_goodbye(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Disconnect
}
fn is_answer(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Data && packet.reliable
}
/// The capped frame's answer is reliable: its datagram is lost, the server resends it, and only once the
/// peer has acknowledged it does the goodbye follow. Neither transmission acknowledges the capped frame.
#[test]
fn a_lost_capped_answer_is_resent_and_the_goodbye_waits_for_its_acknowledgement() -> Result<()> {
let clock = Clock::new(81_000);
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
let (mut peer, capped) = peer_at_the_cap(&mut server, 81)?;
let first = peer.drain();
assert!(first.iter().any(is_answer), "fixture: the answer was sent, and is lost here");
assert!(!first.iter().any(is_goodbye), "no goodbye before the answer is acknowledged");
assert!(
!first.iter().any(|packet| acknowledges(packet, capped)),
"nothing sent for the capped frame acknowledges it"
);
assert_eq!(server.session_count(), 1);
clock.set_monotonic(81_000 + 60);
server.maintain()?;
let resent = peer.drain();
let answer = resent.iter().find(|packet| is_answer(packet)).expect("the lost answer is resent");
assert!(!acknowledges(answer, capped), "the resent answer acknowledges nothing the peer sent");
assert!(!resent.iter().any(is_goodbye), "still no goodbye");
let delivered = peer.session.receive(answer, 4)?.delivered;
let Ok(CultNetMessage::Error { code, .. }) =
decode_cultnet_message_from_slice(&delivered[0].payload, CultNetWireContract::CultNetSchemaV0)
else {
panic!("the resent answer is a typed refusal");
};
assert_eq!(code, Some(CultNetErrorCode::MessageInvalid));
peer.send_packet(&peer.session.create_ack_for(answer.sequence))?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
assert!(peer.drain().iter().any(is_goodbye), "acknowledged: the goodbye follows");
assert_eq!(server.session_count(), 0);
Ok(())
}
/// A peer that never acknowledges the answer is not kept for ever: the session ends after the grace.
#[test]
fn a_capped_session_whose_answer_is_never_acknowledged_ends_after_the_grace() -> Result<()> {
let clock = Clock::new(82_000);
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
let (peer, _) = peer_at_the_cap(&mut server, 82)?;
peer.drain();
clock.set_monotonic(82_000 + 1_999);
server.maintain()?;
assert!(!peer.drain().iter().any(is_goodbye), "inside the grace");
assert_eq!(server.session_count(), 1);
clock.set_monotonic(82_000 + 2_000);
server.maintain()?;
assert!(peer.drain().iter().any(is_goodbye), "the grace ran out");
assert_eq!(server.session_count(), 0);
Ok(())
}
/// Nothing behind the capped frame is served while its answer is delivered.
#[test]
fn nothing_behind_the_capped_frame_is_served() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(83_000), sink.clone(), Source::default())?;
let (mut peer, _) = peer_at_the_cap(&mut server, 83)?;
peer.send_payload(valid_put("behind-the-cap"))?;
for _ in 0..3 {
server.poll_once()?;
}
assert!(sink.0.lock().unwrap().receipts.is_empty(), "the put behind the cap was not stored");
Ok(())
}
/// A Ping inside the grace is answered with a Pong that acknowledges nothing: its ack fields are built from
/// what the session received, which includes the capped frame.
#[test]
fn a_pong_in_the_grace_after_the_cap_acknowledges_nothing() -> Result<()> {
let mut server = server(Default::default(), Clock::new(84_000), Sink::default(), Source::default())?;
let (mut peer, capped) = peer_at_the_cap(&mut server, 84)?;
peer.drain();
let ping = peer.session.create_ping(b"alive".to_vec());
peer.send_packet(&ping)?;
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
let pong = peer
.drain()
.into_iter()
.find(|packet| packet.packet_type == cultnet_rs::CultNetRudpPacketType::Pong)
.expect("the ping is answered");
assert_eq!(pong.payload, b"alive");
assert!(!acknowledges(&pong, capped), "the Pong acknowledges the capped frame");
assert_eq!((pong.ack, pong.ack_mask), (0, 0));
Ok(())
}
/// The grace deadline saturates: at the end of the clock the answer is not given up on at once.
#[test]
fn the_grace_deadline_does_not_wrap_at_the_end_of_the_clock() -> Result<()> {
let mut server = server(Default::default(), Clock::new(u64::MAX - 10), Sink::default(), Source::default())?;
let (peer, _) = peer_at_the_cap(&mut server, 85)?;
peer.drain();
server.maintain()?;
assert!(!peer.drain().iter().any(is_goodbye), "the deadline wrapped and the session ended before its grace");
assert_eq!(server.session_count(), 1);
Ok(())
}

View file

@ -1,18 +1,22 @@
# Changelog # Changelog
All notable changes to `cultmesh-ts` are recorded here, following All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## Unreleased ## Unreleased
### Breaking ### Breaking
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean. - `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema. - `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
- `createRudpDocumentServer` takes `writeAuthorityTimeoutMs` (default `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`, 5000; a value outside 1..2147483647 throws `RangeError`): a write authority whose `decide()` has not settled by then refuses the write like one that throws (`write_not_authorized`, and `onError` hears of it), so a hung authority no longer holds up the session's later frames.
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a typed `message_invalid` refusal (`details.field` is `message`). A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `message_invalid` naming that field. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`. - Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
### Added ### Added
- `createRudpDocumentServer` ends a session after `CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES` (8) consecutive undecodable frames or consecutive deletes the write authority refuses; fewer are answered and the session is kept. Any decoded frame resets the undecodable run, any decoded non-refused-delete frame resets the delete run. The same cap holds in every runtime.
- `CultMeshCatalogValue<TDocument, TSchema>`, exported. - `CultMeshCatalogValue<TDocument, TSchema>`, exported.
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type. - `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.

View file

@ -1,6 +1,6 @@
# CultMesh TS # CultMesh TS
`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local `@gamecult/cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
native CultMesh stream implementation used by Mimir and Fensalir; this package native CultMesh stream implementation used by Mimir and Fensalir; this package
mirrors the typed declaration and negotiation shapes without owning the hot mirrors the typed declaration and negotiation shapes without owning the hot
@ -21,7 +21,7 @@ WASM route. Runtime code can ask for a whole XY rect, then adapt it to the
current wire schema at the edge: current wire schema at the edge:
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
const request = CultMesh.viewportRequest(viewport, [3, 8]); const request = CultMesh.viewportRequest(viewport, [3, 8]);
@ -215,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render);
``` ```
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>(); const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
const stopWatching = verses.watch((verse) => { const stopWatching = verses.watch((verse) => {
@ -274,7 +274,7 @@ const noteShards = shards.list({
}); });
``` ```
Schema and shard catalog factories delegate to `cultnet-ts`; the branded Schema and shard catalog factories delegate to `@gamecult/cultnet-ts`; the branded
`CultMesh` entrypoint does not create a second owner for schema discovery or `CultMesh` entrypoint does not create a second owner for schema discovery or
topology truth. topology truth.
@ -290,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details.
import { import {
CultMeshProviderRudpTransport, CultMeshProviderRudpTransport,
CultMeshProviderSession, CultMeshProviderSession,
} from "cultmesh-ts"; } from "@gamecult/cultmesh-ts";
const odinTransport = new CultMeshProviderRudpTransport({ const odinTransport = new CultMeshProviderRudpTransport({
endpoint: "rudp://127.0.0.1:17871", endpoint: "rudp://127.0.0.1:17871",
@ -345,9 +345,11 @@ evidence for the requested provider identity.
## RUDP Helpers ## RUDP Helpers
`createRudpDocumentServer` refuses every peer put and delete unless given a `writeAuthority` (`new CultMeshGrantAllWrites()` accepts all). The authority is asked before a write's payload is sized or decoded and before any handler runs, and a refusal is the typed `write_not_authorized`. Residual: ungranted peers can still distinguish registered from unregistered schemas by timing.
Node runtimes can build the shared CultNet reliable-UDP transport from the Node runtimes can build the shared CultNet reliable-UDP transport from the
branded CultMesh entrypoint while the transport semantics stay owned by branded CultMesh entrypoint while the transport semantics stay owned by
`cultnet-ts`: `@gamecult/cultnet-ts`:
```ts ```ts
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040); const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
@ -472,7 +474,7 @@ The split is deliberate:
The mirrored API shape is `CultMeshStreamCatalog`: The mirrored API shape is `CultMeshStreamCatalog`:
```ts ```ts
import { CultMesh } from "cultmesh-ts"; import { CultMesh } from "@gamecult/cultmesh-ts";
const streams = CultMesh.createStreamCatalog(); const streams = CultMesh.createStreamCatalog();
const stopWatchingStreams = streams.watch((stream) => { const stopWatchingStreams = streams.watch((stream) => {
@ -533,7 +535,7 @@ nonlocal fallback.
## Content Chunks over HTTP(S) ## Content Chunks over HTTP(S)
`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD` `@gamecult/cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the `<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
handler reads only the query. Every chunk is hashed against its name before a handler reads only the query. Every chunk is hashed against its name before a
byte is sent, so a corrupt or swapped file answers 500 and never leaves the byte is sent, so a corrupt or swapped file answers 500 and never leaves the
@ -550,7 +552,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma
```ts ```ts
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createServer } from "node:https"; import { createServer } from "node:https";
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content"; import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "@gamecult/cultmesh-ts/content";
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), { const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash), onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),

View file

@ -1,5 +1,5 @@
{ {
"name": "cultmesh-ts", "name": "@gamecult/cultmesh-ts",
"version": "0.2.0", "version": "0.2.0",
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
"main": "dist/index.js", "main": "dist/index.js",
@ -48,8 +48,8 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"@gamecult/cultcache-ts": "^0.14.0", "@gamecult/cultcache-ts": "^0.15.0",
"cultnet-ts": "^0.3.0", "@gamecult/cultnet-ts": "^0.3.0",
"koffi": "3.3.0" "koffi": "3.3.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -2,7 +2,7 @@ import { constants } from "node:fs";
import { open } from "node:fs/promises"; import { open } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { join } from "node:path"; import { join } from "node:path";
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "cultnet-ts/content"; import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "@gamecult/cultnet-ts/content";
/** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */ /** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */
export interface ContentChunkRequest { method: string; url: string; } export interface ContentChunkRequest { method: string; url: string; }

View file

@ -1,5 +1,5 @@
import { createSocket, type Socket } from "node:dgram"; import { createSocket, type Socket } from "node:dgram";
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultCache, CultCache,
@ -20,6 +20,9 @@ import {
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportConnection,
rudpClientBindHost, rudpClientBindHost,
CultNetSchemaCatalog, CultNetSchemaCatalog,
cultNetMessageInvalidError,
cultNetTextError,
cultNetWriteNotAuthorizedError,
CultNetShardCatalog, CultNetShardCatalog,
cultNetBuiltinSchemaRegistry, cultNetBuiltinSchemaRegistry,
decodeRudpPacket, decodeRudpPacket,
@ -31,6 +34,7 @@ import {
sendRudpDatagram, sendRudpDatagram,
type CultNetErrorMessage, type CultNetErrorMessage,
type CultNetDocumentBinding, type CultNetDocumentBinding,
type CultNetDocumentDeleteMessage,
type CultNetMessage, type CultNetMessage,
type CultNetOperationRequestMessage, type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage, type CultNetOperationResponseMessage,
@ -40,7 +44,7 @@ import {
type CultNetSchemaCatalogOptions, type CultNetSchemaCatalogOptions,
type CultNetSnapshotResponseRawMessage, type CultNetSnapshotResponseRawMessage,
type CultNetWireContract, type CultNetWireContract,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import { import {
CultMeshQuicRealtimeConnector, CultMeshQuicRealtimeConnector,
@ -3991,6 +3995,63 @@ export type CultMeshRudpDocumentPut = {
| { readonly decoded: false; readonly payload: DocumentPayload } | { readonly decoded: false; readonly payload: DocumentPayload }
); );
/** The peer a write arrived from: the datagram's source, nothing the peer claimed. */
export interface CultMeshRemoteWritePeer {
address: string;
family: string;
port: number;
}
export type CultMeshRemoteWriteOperation = "put" | "delete";
/**
* One peer write as the authority sees it. `schemaId` is the canonical id the registry resolved for the write (the id
* as sent when no binding takes it), never an alias the peer used when a binding resolves it. `shardId` and
* `shardEpoch` are as a put carried them, `null` when it sent none (always `null` for a delete, which has none); a
* put carrying either with the wrong type is refused before any authority is asked.
*/
export interface CultMeshRemoteWriteRequest {
peer: CultMeshRemoteWritePeer;
operation: CultMeshRemoteWriteOperation;
schemaId: string;
recordKey: string;
shardId: string | null;
shardEpoch: number | null;
}
export interface CultMeshRemoteWriteDecision {
granted: boolean;
/** Told to the refused peer after `write_not_authorized:`; keep it free of anything secret. */
reason?: string;
}
/**
* The one question a document server asks before it applies a peer's put or delete (C# `ICultNetRemoteWriteAuthority`).
* A server with no authority refuses every peer write; one whose authority throws, rejects or answers anything but
* `granted: true` refuses too.
*/
export interface CultMeshRemoteWriteAuthority {
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
}
/**
* Grants every peer write. For a host that has already authenticated its peers, or a test rig: it is an explicit
* choice because serving without an authority refuses writes.
*/
export class CultMeshGrantAllWrites implements CultMeshRemoteWriteAuthority {
decide(): CultMeshRemoteWriteDecision {
return { granted: true };
}
}
/** A delete as `onDocumentDeleteRaw` receives it, after the authority granted it. */
export interface CultMeshRudpDocumentDelete {
messageId: string;
schemaId: string;
recordKey: string;
remote: CultMeshRemoteWritePeer;
}
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> { export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
binding: CultNetDocumentBinding<TDefinition>; binding: CultNetDocumentBinding<TDefinition>;
recordKey: string; recordKey: string;
@ -4030,10 +4091,24 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
documents: CultNetDocumentRegistry; documents: CultNetDocumentRegistry;
getCache?: () => Promise<CultCache> | CultCache; getCache?: () => Promise<CultCache> | CultCache;
onError?: (error: Error) => void; onError?: (error: Error) => void;
/**
* Who may put and delete. Without it every peer put and delete is refused with a typed `write_not_authorized`
* (and `onDocumentPutRaw` is never called); pass `new CultMeshGrantAllWrites()` to accept them all. The authority is
* asked before the document is sized, resolved or decoded.
*/
writeAuthority?: CultMeshRemoteWriteAuthority;
/**
* How long the write authority has to answer before the write is refused (default
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`). A `decide()` that never settles is a refusal like any
* other, so it cannot hold up the frames that follow it on the session.
*/
writeAuthorityTimeoutMs?: number;
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) => onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
| CultMeshRudpDocumentReceipt | CultMeshRudpDocumentReceipt
| void | void
| Promise<CultMeshRudpDocumentReceipt | void>; | Promise<CultMeshRudpDocumentReceipt | void>;
/** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */
onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise<void>;
onOperationRequest?: ( onOperationRequest?: (
request: CultNetOperationRequestMessage, request: CultNetOperationRequestMessage,
session: CultMeshRudpServerSession, session: CultMeshRudpServerSession,
@ -4043,6 +4118,19 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
sessionTimeoutMs?: number; sessionTimeoutMs?: number;
} }
/** A write authority that has not answered in this long has refused the write. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS = 5_000;
/**
* How many consecutive refused frames of one kind a `createRudpDocumentServer` session may send before it is
* ended: undecodable frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any
* decoded frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
* frame that reaches this number is answered too, then the session ends. The same number is the cap in every runtime.
*/
export const CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES = 8;
/** How long a session whose capped frame was answered waits for the peer to acknowledge the answers before it ends anyway. */
const CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS = 2000;
/** Replies larger than one fragment are split into fragments of at most this many bytes. */ /** Replies larger than one fragment are split into fragments of at most this many bytes. */
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200; export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
@ -4095,7 +4183,29 @@ export interface CultMeshRudpDocumentServer {
close(): void; close(): void;
} }
/**
* A write the peer refused: the typed `cultnet.error.v0` it answered with (`write_not_authorized`,
* `message_invalid`, ...), whichever of the transport ack and the result frame came first.
*/
export class CultMeshRudpWriteRefusedError extends Error {
readonly code: string | undefined;
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>;
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>) {
super(response.error);
this.name = "CultMeshRudpWriteRefusedError";
this.code = response.code ?? undefined;
this.response = response;
}
}
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions { export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
/**
* How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not
* admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no
* success frame, so the put is reported published once this window passes with no refusal; a success reply
* ends the wait early. Default 500 ms.
*/
resultWindowMs?: number;
messageId?: string; messageId?: string;
sourceRuntimeId?: string; sourceRuntimeId?: string;
sourceAgentId?: string; sourceAgentId?: string;
@ -5459,6 +5569,11 @@ export class CultMesh {
closed: boolean; closed: boolean;
/** A packet it owed could never be sent, so the session ended. */ /** A packet it owed could never be sent, so the session ended. */
unsendable: boolean; unsendable: boolean;
/** Consecutive undecodable frames, and consecutive deletes the authority refused. */
unreadableInARow: number;
refusedDeletesInARow: number;
/** Set once the peer's capped frame is answered: the session ends when the answers are acknowledged, or at this time. */
endingDeadlineMs?: number;
work: Promise<void>; work: Promise<void>;
}; };
const sessions = new Map<string, SessionRecord>(); const sessions = new Map<string, SessionRecord>();
@ -5466,6 +5581,11 @@ export class CultMesh {
let sendFailures = 0; let sendFailures = 0;
const resendPollMs = Math.max(10, options.resendPollMs ?? 25); const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000); const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
const decideTimeoutMs = options.writeAuthorityTimeoutMs ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS;
// A timer past 2^31-1 ms (or NaN) fires after 1 ms in Node, which would refuse every write that is not instant.
if (!(decideTimeoutMs > 0 && decideTimeoutMs <= 0x7fffffff)) {
throw new RangeError("CultMesh RUDP document server writeAuthorityTimeoutMs must be greater than zero and at most 2147483647.");
}
const wireContract = options.wireContract ?? "cultnet.schema.v0"; const wireContract = options.wireContract ?? "cultnet.schema.v0";
const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES; const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES;
if (!(maxFragmentBytes > 0)) { if (!(maxFragmentBytes > 0)) {
@ -5508,6 +5628,8 @@ export class CultMesh {
connectPayload, connectPayload,
closed: false, closed: false,
unsendable: false, unsendable: false,
unreadableInARow: 0,
refusedDeletesInARow: 0,
session: new CultNetRudpSession({ session: new CultNetRudpSession({
connectionId, connectionId,
initialSequence: options.initialSequence, initialSequence: options.initialSequence,
@ -5572,15 +5694,36 @@ export class CultMesh {
record: SessionRecord, record: SessionRecord,
payload: Uint8Array, payload: Uint8Array,
): Promise<void> { ): Promise<void> {
if (record.closed) return; // Nothing after the capped frame is served while its answer is delivered.
const message = parseCultNetMessage(decodeWireValue(payload), wireContract); if (record.closed || record.endingDeadlineMs !== undefined) return;
let decoded: unknown;
let message: CultNetMessage;
try {
decoded = decodeWireValue(payload);
message = parseCultNetMessage(decoded, wireContract);
} catch (error) {
// A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so
// an ungranted peer hears the same refusal for it as for any other write, not a parse error.
const write = readableWrite(decoded);
record.unreadableInARow += 1;
if (decoded !== undefined && !write) {
// It decodes but is no message the parser takes (an unknown schemaVersion, a non-map) and is no write the
// authority could judge: answered and counted like any other unreadable frame.
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the message."));
} else if (!await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) {
reportError(error);
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
}
if (record.unreadableInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.unreadableInARow = 0;
if (message.schemaVersion !== "cultnet.document_delete.v0") record.refusedDeletesInARow = 0;
switch (message.schemaVersion) { switch (message.schemaVersion) {
case "cultnet.snapshot_request.v0": { case "cultnet.snapshot_request.v0": {
if (!options.getCache) { if (!options.getCache) {
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no cache for snapshot requests."));
schemaVersion: "cultnet.error.v0",
error: "CultMesh RUDP document server has no cache for snapshot requests.",
});
return; return;
} }
let reply: CultNetMessage; let reply: CultNetMessage;
@ -5609,11 +5752,32 @@ export class CultMesh {
sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message)); sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
return; return;
case "cultnet.document_put_raw.v0": case "cultnet.document_put_raw.v0":
{
// The parser passes shard fields of any type through a put. The authority is asked first, with the shard
// fields it can trust (null when wrong-typed), so an ungranted peer hears only the refusal; a granted peer
// whose shard fields are wrong-typed is then refused as malformed.
const { shardId, shardEpoch } = message as { shardId?: unknown; shardEpoch?: unknown };
const malformedShard =
(shardId !== undefined && typeof shardId !== "string") ||
(shardEpoch !== undefined && !Number.isSafeInteger(shardEpoch));
if (await writeRefused(
record, "put", message.document.schemaId, message.document.recordKey,
malformedShard ? null : (shardId as string | undefined) ?? null,
malformedShard ? null : (shardEpoch as number | undefined) ?? null,
)) return;
if (malformedShard) {
sendSchemaMessage(record, cultNetMessageInvalidError(
typeof shardId !== "undefined" && typeof shardId !== "string" ? "shardId" : "shardEpoch",
"Raw put messages must carry a string shardId and an integer shardEpoch.",
));
return;
}
}
if (options.onDocumentPutRaw) { if (options.onDocumentPutRaw) {
const unservable = unservablePut(message); const unservable = unservablePut(message);
if (unservable) { if (unservable) {
reportError(unservable); reportError(unservable);
sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message }); sendSchemaMessage(record, cultNetTextError(unservable.message));
return; return;
} }
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents)); const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents));
@ -5623,32 +5787,119 @@ export class CultMesh {
} catch (error) { } catch (error) {
// The put is stored: the peer must hear that its receipt is not coming. // The put is stored: the peer must hear that its receipt is not coming.
reportError(error); reportError(error);
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError(`CultMesh RUDP document server stored a put but could not send its receipt.`));
schemaVersion: "cultnet.error.v0",
error: `CultMesh RUDP document server stored a put but could not send its receipt.`,
});
} }
} }
} }
return; return;
case "cultnet.document_delete.v0": {
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) {
record.refusedDeletesInARow += 1;
if (record.refusedDeletesInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
return;
}
record.refusedDeletesInARow = 0;
if (!options.onDocumentDeleteRaw) {
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler."));
return;
}
await options.onDocumentDeleteRaw({
messageId: message.messageId,
schemaId: message.schemaId,
recordKey: message.recordKey,
remote: { ...record.remote },
});
return;
}
case "cultnet.operation_request.v0": case "cultnet.operation_request.v0":
if (!options.onOperationRequest) { if (!options.onOperationRequest) {
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no operation handler."));
schemaVersion: "cultnet.error.v0",
error: "CultMesh RUDP document server has no operation handler.",
});
return; return;
} }
sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record)));
return; return;
default: default:
sendSchemaMessage(record, { sendSchemaMessage(record, cultNetTextError(`Unsupported CultMesh RUDP document request ${message.schemaVersion}.`));
schemaVersion: "cultnet.error.v0",
error: `Unsupported CultMesh RUDP document request ${message.schemaVersion}.`,
});
} }
} }
function clipUtf8(text: string, maxBytes: number): string {
const bytes = Buffer.from(text, "utf8");
return bytes.byteLength <= maxBytes ? text : new TextDecoder().decode(bytes.subarray(0, maxBytes)).replace(/\uFFFD+$/, "");
}
/** The put or delete a decoded frame claims to be, with the ids it carries when they are strings. */
function readableWrite(value: unknown): { operation: CultMeshRemoteWriteOperation; schemaId: string; recordKey: string } | undefined {
if (typeof value !== "object" || value === null) return undefined;
const frame = value as { schemaVersion?: unknown; schemaId?: unknown; recordKey?: unknown; document?: unknown };
const str = (v: unknown): string => (typeof v === "string" ? v : "");
if (frame.schemaVersion === "cultnet.document_delete.v0") {
return { operation: "delete", schemaId: str(frame.schemaId), recordKey: str(frame.recordKey) };
}
if (frame.schemaVersion === "cultnet.document_put_raw.v0") {
const document = (typeof frame.document === "object" && frame.document !== null ? frame.document : {}) as { schemaId?: unknown; recordKey?: unknown };
return { operation: "put", schemaId: str(document.schemaId), recordKey: str(document.recordKey) };
}
return undefined;
}
/**
* Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and
* answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused.
* No authority refuses; an authority that throws, rejects or does not say `granted: true` refuses too (fail
* closed), and a failure is reported without quoting it.
*/
async function writeRefused(
record: SessionRecord,
operation: CultMeshRemoteWriteOperation,
schemaId: string,
recordKey: string,
shardId: string | null,
shardEpoch: number | null,
): Promise<boolean> {
let reason: string | undefined;
const authority = options.writeAuthority;
if (!authority) {
reason = "no remote write authority is configured";
} else {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timedOut = Symbol("decide timeout");
const decision = await Promise.race([
Promise.resolve().then(() => authority.decide({
peer: { ...record.remote },
operation,
schemaId: options.documents.canonicalSchemaId(schemaId) ?? schemaId,
recordKey,
shardId,
shardEpoch,
})),
new Promise<typeof timedOut>((resolve) => { timer = setTimeout(() => resolve(timedOut), decideTimeoutMs); }),
]);
if (decision === timedOut) {
reportError(new Error(`CultMesh remote write authority did not answer within ${decideTimeoutMs} ms; the write was refused.`));
reason = "the write authority timed out";
} else {
if (decision?.granted === true) return false;
reason = typeof decision?.reason === "string" ? decision.reason : undefined;
}
} catch (error) {
reportError(new Error(`CultMesh remote write authority failed (${error instanceof Error ? error.name : typeof error}); the write was refused.`));
reason = "the write authority failed";
} finally {
clearTimeout(timer);
}
}
try {
// The key is the peer's and the reason the authority's: both are clipped so the refusal fits `maxPayloadBytes`
// whatever they are (the peer is told the clipped key; a `maxPayloadBytes` under about 512 can still not carry it).
sendSchemaMessage(record, cultNetWriteNotAuthorizedError(clipUtf8(recordKey, 64), reason === undefined ? undefined : clipUtf8(reason, 96)));
} catch (error) {
reportError(error);
}
return true;
}
function publicSession(record: SessionRecord): CultMeshRudpServerSession { function publicSession(record: SessionRecord): CultMeshRudpServerSession {
return { return {
sessionId: record.sessionId, sessionId: record.sessionId,
@ -5698,6 +5949,17 @@ export class CultMesh {
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {}); sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
} }
/**
* A session that reached the cap of consecutive refused frames: its last refusal went out like any reply
* (reliable, so a lost datagram is resent), and the session ends once the peer has acknowledged every reply or
* the grace runs out. The resend timer ends it.
*/
function endCappedSession(record: SessionRecord): void {
const key = `${record.remote.address}:${record.remote.port}`;
if (sessions.get(key) !== record) return;
record.endingDeadlineMs ??= Date.now() + CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS;
}
/** The session owes its peer a packet that can never be sent as built. */ /** The session owes its peer a packet that can never be sent as built. */
function endUnsendableSession(record: SessionRecord, error: Error): void { function endUnsendableSession(record: SessionRecord, error: Error): void {
if (record.unsendable) return; if (record.unsendable) return;
@ -5741,7 +6003,7 @@ export class CultMesh {
*/ */
function refuse(record: SessionRecord, text: string): void { function refuse(record: SessionRecord, text: string): void {
if (record.closed) return; if (record.closed) return;
const payload = encodeSchemaMessage({ schemaVersion: "cultnet.error.v0", error: text }); const payload = encodeSchemaMessage(cultNetTextError(text));
for (const packet of record.session.sendMany("schema", payload, { for (const packet of record.session.sendMany("schema", payload, {
reliable: false, reliable: false,
ordered: false, ordered: false,
@ -5831,6 +6093,11 @@ export class CultMesh {
closeSession(record); closeSession(record);
continue; continue;
} }
if (record.endingDeadlineMs !== undefined
&& (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) {
endRefusedSession(key, record);
continue;
}
for (const packet of record.session.dueResends(nowMs)) { for (const packet of record.session.dueResends(nowMs)) {
sendPacket(record, packet); sendPacket(record, packet);
} }
@ -5912,11 +6179,23 @@ export class CultMesh {
requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(runtimeId, "runtimeId");
requireNonEmpty(recordKey, "recordKey"); requireNonEmpty(recordKey, "recordKey");
const registry = new CultNetDocumentRegistry([binding]); const registry = new CultNetDocumentRegistry([binding]);
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
try { try {
// A transport ack is not admission: the put completes on its result frame. A refusal rejects with the
// typed error whether it arrives before the ack, after it, or inside the result window.
let refusal: CultMeshRudpWriteRefusedError | undefined;
let succeeded = false;
let wake: (() => void) | undefined;
peer.on("message", (message: CultNetMessage) => {
if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message);
else if ("messageId" in message && message.messageId === messageId) succeeded = true;
else return;
wake?.();
});
peer.send(registry.createRawDocumentPutMessage( peer.send(registry.createRawDocumentPutMessage(
binding, binding,
options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`, messageId,
recordKey, recordKey,
value, value,
{ {
@ -5927,6 +6206,13 @@ export class CultMesh {
}, },
)); ));
await peer.flush(options.flushTimeoutMs ?? 30_000); await peer.flush(options.flushTimeoutMs ?? 30_000);
if (!refusal && !succeeded) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, options.resultWindowMs ?? 500);
wake = () => { clearTimeout(timer); resolve(); };
});
}
if (refusal) throw refusal;
} finally { } finally {
peer.close(); peer.close();
} }
@ -5945,6 +6231,7 @@ export class CultMesh {
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => { const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000); const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
const onMessage = (message: CultNetMessage): void => { const onMessage = (message: CultNetMessage): void => {
if (message.schemaVersion === "cultnet.error.v0") { cleanup(); reject(new CultMeshRudpWriteRefusedError(message)); return; }
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return; if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
try { try {
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload); const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createSocket, type Socket } from "node:dgram"; import { createSocket, type Socket } from "node:dgram";
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
import { encodeWireValue } from "cultnet-ts/wire"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { import {
CultNetPeer, CultNetPeer,
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportConnection,
@ -9,7 +9,7 @@ import {
type CultNetOperationRequestMessage, type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage, type CultNetOperationResponseMessage,
type CultNetRawDocumentRecord, type CultNetRawDocumentRecord,
} from "cultnet-ts"; } from "@gamecult/cultnet-ts";
import type { import type {
CultMeshProviderCommand, CultMeshProviderCommand,

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