Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
1b95dd67ee Merge CultMath HLSL parity into CultCache store routing
Some checks failed
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
Brings codex/cultmath-hlsl-parity (d4c43ed) into the Cut 6 branch so Aetheria's
CultMath adoption and the CultCache Studio land on one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:57:01 +02:00
Meta Vens
741d212086 Note that init read-only presentation depends on the runtime's modreq
IsInitOnly reads the IsExternalInit required modifier, which is unverified on
Unity's Mono. Where it is not reported, init members show editable; reflection
assigns them anyway, so edits still round-trip. No code change: no cheaper
robust check exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:55:27 +02:00
Meta Vens
ccf9bd0c0c Show an unkeyed struct's fields or its properties, never both
Unity's Quaternion showed eulerAngles beside x/y/z/w, Rect about eleven rows
over four values, and Bounds center/extents/size/min/max: settable properties
alias the fields they wrap. A struct with public fields now shows only those; a
struct without shows its settable properties, so record structs and CultMath
matrices (properties over private rows) still get clean rows and vectors show
fields only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:55:03 +02:00
Meta Vens
baa8de0baf Disable Studio Add for record types the model cannot create
The window lost its disabled state when record creation moved into the model:
Add and Create were enabled for abstract types and threw on click, and
CreateElement(type, type) on an abstract [Union] type threw "X is not a choice
for X". CultInspectorModel.CanCreate is now the one rule CreateDefault and the
window both read, a slot's own type is always a choice, and an abstract one
returns the "abstract or no parameterless constructor" notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:53:32 +02:00
Meta Vens
bddec0fe4d Re-read the manifest outside the unleased load's exception filter
An exception thrown inside a `when` filter is swallowed as false, so a manifest
re-read failing mid-File.Replace turned a race that should retry into a thrown
torn-page error. The re-read now runs in the handler, and a failed re-read
counts as moved. Parallel.For's AggregateException is unwrapped where the pages
are read, so the page's own failure propagates and the torn check no longer
walks wrappers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:51:37 +02:00
Meta Vens
d4c43ed265 Say where intrinsic rules are pinned and that NaN does not propagate
The mirror compiles under using static CultMath.math, so it proves composition,
not intrinsic rules; HlslSemanticsTests alone pins those. design.md also states
where FMin/FMax/Saturate turn NaN or infinity into numbers and tells callers to
check isnan first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:47:10 +02:00
Meta Vens
19e23db211 Make double min/max follow DXIL FMin/FMax
dxc lowers double min/max to dx.op.binary.f64 opcodes 36/35 (FMin/FMax),
the same operations as float, so Math.Min/Math.Max (NaN-propagating) was the
wrong rule. Double min/max now use the float formulas, with NaN and signed-zero
assertions beside the float ones. Tracked Unity DLL rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:47:10 +02:00
Meta Vens
ff03866c8a Take record creation and type matching out of the Studio window
The window decided whether a record type could be added
(GetConstructor(Type.EmptyTypes)), made it with Activator.CreateInstance,
and matched records to a type with IsAssignableFrom twice, duplicating
RecordCandidates.

Add and Create now ask the model's CreateElement, and its notice is the
refusal. The record list and the per-type counts come from
RecordCandidates. The window only renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:38:24 +02:00
Meta Vens
8e0cd3e32d Compare dictionary keys under their owning document's serializer
Key identity serialized a key with the options of the key type's own
assembly, but the store writes it inside the document with the document
assembly's options, [CultCacheFormatterResolver] resolvers included. A key
that failed to serialize fell back to comparison by ToString.

The inspector codec now takes the owning document type, ReplaceKey and
FreshKey take it, and a key serializes as its declared key type under that
document's options, the store's own path. A key the document cannot
serialize is refused with a notice; the text fallback is deleted.

The comment and README said "the dictionary's own comparer"; it is the
rebuilt dictionary type's default comparer, and now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:38:24 +02:00
Meta Vens
c9a7d9c2a4 Drop the Studio edit copy whenever a frame does not finish drawing
The copy was dropped only when a claimed drawer threw. A built-in drawing
exception (BuildDictionary, SetValue, a throwing constructor, Clone) or an
ExitGUIException escaped OnGUI with the copy alive, and the next change
committed the half-made edit.

The window now takes the copy out of _edit before drawing and puts it back
only when the document drew to the end with no drawer failing. Every
abnormal exit, ExitGUIException included, leaves the next frame to clone
afresh from the cache.

ExitGUIException is dropped too, not exempted: Unity's pickers exit on the
click that opens them and deliver the pick in a later event, which lands
on the fresh copy and commits normally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:36:57 +02:00
Meta Vens
7d59021b57 Show unkeyed structs by their writable surface, not a lock rule
The unkeyed struct rule found auto-properties by the compiler's
<Name>k__BackingField name and marked the whole struct Unsupported when
one was get-only or a field was readonly. Record structs fell out with a
generic reason, and settable or init properties beside a field were
silently hidden. Editing is in place, so the lock protected nothing.

An unkeyed struct now shows its public fields (readonly ones read-only)
and its public properties that have a setter (init-only ones read-only).
Get-only properties are computed or kept by the in-place edit, so they are
not rows; vector swizzles stay out of the inspector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:34:56 +02:00
Meta Vens
838bbfffec Retry an unlocked directory load only when the manifest moved
A missing or hash-failing page under an unchanged manifest is corruption,
not a writer mid-commit, but every InvalidDataException was retried five
times and reported as "did not settle" with the real cause buried. The
retry now also requires the manifest bytes to have changed; otherwise the
corruption error is thrown on the first attempt. A vanished page directory
is a race under the same condition.

The reload test's writer now commits under every unleased read, so it only
settles if the retry takes the lease that commit created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:33:43 +02:00
Meta Vens
08e213dd7c Compare mirror results by bit pattern and say what that proves
SequenceEqual on floats treats -0 as 0. The mirror test now compares
SingleToInt32Bits, with any NaN equal to any NaN. Its summary and
design.md now say the test proves CultMath.hlsl's text computes the same
float32 results as C# math on the CPU, not that a GPU agrees: driver sin
precision alone makes cultmath_hash and value noise differ on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:32:43 +02:00
Meta Vens
9407da0461 Match float min, max, clamp, and saturate to DXIL FMin/FMax/Saturate
dxc lowers float min/max/clamp to dx.op FMin/FMax (36/35) and saturate to
dx.op Saturate (7). DXIL.rst defines them: FMin(a, b) is a < b ? a : b,
FMax(a, b) is a >= b ? a : b, a NaN operand returns the other, and
Saturate is FMin(1, FMax(0, x)). C# used MathF.Min/Max, which pass NaN
through and order signed zeros, so saturate(NaN) was NaN instead of 0.

Every float overload now follows the spec, normalize uses the same max as
its mirror, and the mirror's saturate spells Saturate's operand order
(which maps -0 to +0, where clamp(x, 0, 1) keeps -0). Non-NaN results
change only in the sign of a zero. design.md records the rules and that
dxc marks float math fast, so GPU NaN results are not guaranteed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:32:19 +02:00
Meta Vens
04c7c6bcd8 Refuse a list Add that creates nothing
CreateElement returned null without a notice when the element type itself
had no parameterless constructor, and the Studio added that null. The
notice now follows the result: nothing made is refused, except the null
a Nullable<T> slot legitimately holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:31:45 +02:00
Meta Vens
d298d666d0 Rebuild the Unity CultMath DLL without Source Link, non-incrementally
The previous commit's DLL and pdb came from an incremental build that did
not recompile after the Source Link switch, so the pdb still carried the
commit URL. The script now builds with --no-incremental, so a stale obj
directory cannot decide what the byte check compares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:28:44 +02:00
Meta Vens
a9a2ba69d2 Build the Unity CultMath DLL independent of the commit
The tracked DLL is committed with its source, so a build that embeds the
commit can never match it. Two places named the commit: the informational
version (0.2.0+<sha>) and Source Link's commit URL in the pdb, whose content
id the DLL also carries. The csproj drops the revision from the version, and
the Unity build turns Source Link off; NuGet packs keep it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:27:44 +02:00
Meta Vens
6bc6db68d4 Document where matrix row writes are silently lost
The operator kept the [UnscopedRef] ref this[int] row indexer as is. design.md
and the float2x2/float3x3 indexers now say where m[r][c] = v lands: locals,
array elements, class fields, and ref locals keep the write; a readonly field,
in parameter, property (auto-properties too), or static readonly identity
takes a defensive copy, so the write compiles and is lost. Fix: copy to a
local, write, assign back. Verified by compiling and running each case with
Roslyn against CultMath (no diagnostics; writes land 5, lost 0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:18:00 +02:00
Meta Vens
77ee6fe488 Pin dxc to dxc_2026_07_29 and verify its SHA256
get-dxc.ps1 fetched releases/latest with no pin or hash, so the compiler that
defines parity could change under the tests. It now downloads v1.9.2607's
dxc_2026_07_29.zip (dxcompiler 1.9.0.5402) and refuses a zip whose SHA256 is
not A1DFB116...CD241. The hash was taken from the zip downloaded from
Microsoft's GitHub release and matches the digest GitHub publishes for the
asset. Script ran end to end and installed 1.9.0.5402.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:17:11 +02:00
Meta Vens
2940ca0944 Compare every HLSL mirror function against C# math, bit for bit
Only cultmath_snoise was compared, at four inputs, so a flipped cultmath_step,
a changed hash constant, and a snoise2 tie change all passed. The mirror is
now discovered by reflection: every cultmath_* overload with a math
counterpart (26 functions) runs over 104 deterministic cases - each special
value in every argument (0, -0, +-1, ties, 1e-7, +-1e4, 1e7, NaN), special
pairs, seeded randoms, and vectors with x == y (snoise's x0.x == x0.y) - and
must match exactly. Both sides are float32 C# over the same intrinsics, so no
tolerance. A cultmath_* function with no counterpart must be named in
MirrorOnly. The four-input snoise theory is gone.

Mutations: flipped step args fail (349 mismatches); hash constants changed
by more than one float32 ulp fail (43758.55: 467, 127.2: 289, 74.8: 97).
43758.5453 -> .5454 is the same float32 and cannot fail. snoise2 x0.x > x0.y
-> >= is invisible: over 1,829,415 exact ties the result is bit-identical,
because the off-simplex corner's kernel is <= 0 on the shared edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:17:11 +02:00
Meta Vens
12fae728c8 Reload a directory store pull that a writer committed under
A pull takes the commit lease only when .commit.lock exists. Without it
(no writer has created one yet, or the store was copied without it) the
reader loaded with no lease, and a writer committing between its manifest
read and its page reads left it with a vanished page or a stale manifest.

An unleased pull now keeps its load only if the manifest bytes it read are
still current afterwards; a changed manifest, or a named page that is
missing or fails its content hash, reloads it, at most five times, and a
pull that has not settled by then throws. A retry takes the lease once a
writer has created it. Opening still creates nothing. The lease comment no
longer claims a missing lock means no writer ever committed, and the store
composition contract states the rule.

The test deletes the lock from a seeded store and commits from a second
cache through an internal seam between the manifest read and the page
reads, once replacing the page the reader named and once only adding a key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:16:41 +02:00
Meta Vens
feb61f305c Delete the ignored soft parameter from CultCache.FlushAsync
soft was documented as ignored and changed nothing. Its only caller,
Mimir.CultMeshMedia, now calls FlushAsync().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:12:31 +02:00
Meta Vens
912d9c469a Leave the CultCache Unity manifest to the routing branch
The routing branch drops the org.gamecult.cultmath dependency from
src/GameCult.Unity/Assets/Caching/package.json; bumping it to 0.2.0 here only
manufactured a merge conflict. The file returns to the merge base (8be174b).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:12:06 +02:00
Meta Vens
e0a054a7b4 Match step's NaN result to what dxc emits
dxc 1.9.0.5402 (dxc_2026_07_29, lib_6_3, -HV 2021, -Od) lowers step(e, x) to
fcmp olt x, e; select 0 : 1, so NaN on either side gives 1. The previous
x >= e ? 1 : 0 rule came from prose, not the compiler. math.step and
cultmath_step now use x < e ? 0 : 1 for every overload; the mirror's
cultmath_step compiles to the same fcmp/select as the intrinsic. Unity shader
copy synced; DLL rebuilt and byte-checked by build-unity-package.ps1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:09:49 +02:00
Meta Vens
f00aefd992 Describe the live Studio inspector in its README
The model paragraph now lists the edit decisions the model owns (key
refusal by bytes and by the dictionary comparer, fresh keys, element
choices, integer clamping) and names CreateInspectorModel. The drawer
section states the return-and-GUI.changed contract and the discarded
frame on a throw. The sample drawer draws a hex color string instead of
CultMath.float3, which the package no longer references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:09:02 +02:00
Meta Vens
40d52502e5 Discard the Studio edit copy when a drawer throws
A drawer that threw after mutating a reference value in place left that
half-made mutation in the live edit copy, where the next unrelated change
saved it. DrawDocument now reports a drawer failure, and the window drops
the edit copy uncommitted so the next frame re-clones from the cache; the
error row says nothing from that frame was saved.

The drawer contract no longer invites in-place mutation: a change returns
the new value and sets GUI.changed, and an in-place mutation without
GUI.changed is not saved reliably. Detecting such a mutation would mean
serializing the document every frame, so it is documented, not detected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:08:17 +02:00
Meta Vens
d22f662f7d Decide element choices and integer narrowing in the inspection model
ElementChoices says what a new list element, dictionary value or union
pick may be (a union's declared subtypes, or the slot type itself), and
CreateElement makes one or returns a notice when a subtype has no
parameterless constructor. It replaces CreateUnionValue, whose throw the
IMGUI caught and sent to the console. A dictionary with a union value
type now offers the subtypes too instead of adding a null value.

NarrowInteger clamps an integer edit to its type's range; the IMGUI no
longer converts and swallows overflow itself. IMGUI renders the model's
answers and shows its notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:07:19 +02:00
Meta Vens
d03e376411 Delete the composite constructor heuristic from the inspector
Operator ruling: the heuristic that rebuilt unkeyed structs through a
guessed constructor is removed, not guarded. Gone: the Composite value
kind, CultInspectorShape.Composer, CultInspectorModel.Compose and
Composite, the composite-component flag on CultInspectorMember, the IMGUI
composite rebuild path, and the README text about it.

An unkeyed struct is now edited in place through its public fields. A
struct holding state it cannot write in place (a public readonly field or
a get-only auto-property) is Unsupported with a reason naming the member,
until a drawer claims it. Computed get-only properties are not state and
do not lock a struct.

The composite-rejection test goes with the heuristic; the replacement
test pins both lock rules and fails with either removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:05:30 +02:00
Meta Vens
2a46409017 Decide dictionary key edits in the inspection model
A refused key and an unavailable fresh key are now the model's answer:
ReplaceKey returns the kept key with the notice text, FreshKey returns
null with its notice, and IMGUI only shows them.

A key is taken when another serializes the same or when the dictionary's
own comparer calls them equal, so 0.0 against -0.0 is refused instead of
throwing in BuildDictionary.

Keys never actually compared by bytes: the Studio handed the model the
document codec, whose registry guard throws for any non-document value,
so every key fell back to ToString. CultCacheMessagePack now builds the
model with a value serializer, and the Studio and tests both use it.

Tests pin the comparer rule, the unchanged-key rule (an old empty
reference key survives an edit of its value) and serialized identity
(keys whose ToString collides); each fails with its rule removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:03:00 +02:00
Meta Vens
5de49ebc60 Resolve hidden inspector members to the most-derived declaration
A keyed type that hides a base member with `new` offered two candidates
for one name. The inspector now keeps the most-derived declaration, after
dropping [IgnoreMember] ones, as the registry's own-member scan does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:59:15 +02:00
Meta Vens
10b2c15fb8 Rebuild the CultMath 0.2.0 Unity DLL with the generated surface
Fresh deterministic netstandard2.1 build from the CRLF working copy a checkout
produces; build-unity-package.ps1 byte check passes. The DLL grows from
66,048 to 297,472 bytes, almost all generated swizzles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
7a1bd1ebb1 Document the full HLSL target and its source transformations
design.md states the generated swizzle and mixed-constructor surface, writable
matrix rows, HLSL intrinsic types and NaN behavior, the deferred matrix items
(float4x4, transpose, determinant, _11 names), the exact HLSL-to-C#
transformations the compatibility test applies, and the dxc check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
88eb365c05 Check bridge declarations, not file names; strip manifest BOMs
build-unity-package.ps1 read the bridge's file names to decide whether it
re-declared core CultMath sources. It now parses declarations: core type
names come from src/CultMath (comments stripped), and every bridge source may
declare types only in CultMath.UnityBridge and never a core type name.
Probed by injecting "struct float3" into UnityConversions.cs: the script
throws.

Strips the UTF-8 BOMs this branch added to both Unity package.json files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
360176a7da Compile the real HLSL mirror as C# and with dxc; fix its errors
HlslSourceCompatibilityTests now reads shaders/CultMath.hlsl, applies only
the documented transformations (drop include guards, drop Texture2D/
SamplerState functions, file-scope static const -> const, f literal
suffixes, wrap in a class), and compiles it with Roslyn against CultMath.
Any compile error fails the test (probed with an undeclared call). The
compiled cultmath_snoise(float3/float2) also matches math.snoise.

dxc -T lib_6_3 -HV 2021 rejected the mirror: cultmath_snoise(float2) called
the undeclared cultmath_simplex_mod289/permute. Renamed to the declared
cultmath_snoise_* helpers. Vector const locals became plain locals (C# const
is primitives only), and cultmath_step uses value >= edge like HLSL step and
the C# mirror. dxc 1.9.0.5402 now compiles the file cleanly. The Unity
package's Shaders copy is synced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
5d8ef0c3de Generate every HLSL swizzle and mixed constructor; fix intrinsic types
tools/generate-swizzles.ps1 writes the checked-in src/CultMath/Swizzles.g.cs
as partial structs plus a partial math: for float2/3/4, int2/3/4 and bool2/3/4
every swizzle of length 2-4 including repeats, in xyzw and rgba naming, with
setters only on non-repeating masks, plus single-component r/g/b/a. double2/3
get lengths 2-3 (there is no double4). Every HLSL mixed constructor
(float4(float2, float2), float4(float, float2, float), copy forms, ...) exists
as a struct constructor and, for float/int/bool, a math function. The
hand-written swizzles and mixed constructors it supersedes are deleted.

Matrices store private rows and return ref float2/float3 from the row
indexer ([UnscopedRef], polyfilled for netstandard2.1), so m[1][2] = s
compiles as in HLSL; _mRC are get/set properties over the rows. Equality and
hashing stay value-based.

Intrinsics follow HLSL: sign returns int/intN and yields 0 for NaN; step is
x >= y ? 1 : 0 so NaN yields 0; any/all accept float and int vectors;
min/max/abs on int vectors return int vectors, and abs(int) returns int.

Strips the UTF-8 BOMs this branch added to math.cs, CultMath.csproj and
HlslSemanticsTests.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:02 +02:00
Meta Vens
4716c304ec Lower the CultCache Studio from the inspection model
CultCache Studio 1.1.0 -> 1.2.0.

The IMGUI inspector is now a lowering of CultInspectorModel: it asks the
model for members, shapes, claims, record candidates and key rules and
hands every change back through it (BuildList, BuildDictionary, Compose,
CreateUnionValue, CreateRecordRef). Deleted from the Studio: its own member
discovery, list/dictionary/union classification, default construction,
key identity, the per-type CultMath drawers (composite shapes replace
them) and the nameof GetProperty key lookup. The window edits through
CultInspectorEdit and clones through the model.

Drawer extension point: ICultInspectorDrawer.Draw receives the inspector,
label, declared value type, value and member (also for list elements and
dictionary keys and values). Attribute claims win over type claims, which
win over built-in drawing; CultInspector.DrawDefault hands a value back to
built-in drawing and DrawValue draws a sub-value with claims applied.

The GameCult.Unity.Caching runtime assembly is deleted; its attributes
live in GameCult.Caching. The package no longer depends on
org.gamecult.cultmath.

Game data stays out of Assets: New refuses a path under the project's
Assets folder and says why, Open warns, and the file dialogs start in the
last store's folder or the project root, never Assets.

README names the model/lowering seam and the new drawer contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
ba1119f18c Add the engine-free CultCache inspection model
The CultInspector* attributes move from the Unity-only
GameCult.Unity.Caching runtime assembly into GameCult.Caching, so headless
shared models carry them. CultInspectorAssetPath keeps a System.Type the
consumer names. CultInspectorDrawer claims either a value type (open
generic definitions claim every closed form) or an Attribute subclass.

CultInspectorModel owns what an inspector shows and which edits it may
make, with no engine reference:
- members: registry catalog slots for documents, [Key] slots for other
  MessagePack objects, public fields for unkeyed structs; Label, Hidden,
  ReadOnly, Order, Range, TextArea and AssetPath metadata; assignability by
  the rule the registry persists by.
- shapes: string, integer, float, bool, enum, composite (an unkeyed value
  type rebuilt through the constructor whose parameters match its members,
  which covers the CultMath vectors, rect and Color32 without naming
  them), CultRecordRef<T> with candidate records, rank-1 list or array,
  dictionary, union limited to declared [Union] subtypes, nested, and
  unsupported with a reason (multi-dimensional arrays included).
- CultInspectorDrawerClaims: attribute claims, then type claims, then
  built-in; invalid drawers and contested claims are errors and draw
  nothing.
- dictionary keys by serialized identity (a record reference by its key
  string, null and "" alike), refusal of null, empty-reference and
  duplicate keys, fresh keys, and collections rebuilt as new instances.
- CultInspectorEdit: a codec clone of the stored document; Commit upserts
  the copy, so a refused upsert leaves the cached object unchanged.

ICultRecordRef exposes CultRecordRef<T>.Key without T, replacing the
Studio's by-name GetProperty lookup.

Tests (CultInspectorModelTests): member list and metadata, claim
resolution order, declared-only union choices, record-reference
candidates, dictionary key refusal, refused commit leaving the cached
document unchanged, and shape classification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
b6ed9e5f83 Cover GetByIndex resolving through assignable types
GetByIndexMatchesAssignableTypes: a [CultIndex] on a base member of two
derived documents resolves GetByIndex<Base> to the derived record, throws
naming both keys once the value is ambiguous across the two types, and
still resolves by the concrete type. GetByIndex<T> already filtered
assignable types; no CultCache change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
debe616716 Open a directory store without creating anything
Opening a directory store, read-only or writable, created the .records
folder and its .commit.lock before reading a manifest that might not
exist. Only a write creates them now: PullAll takes the commit lease with
create: false, which opens an existing lock and returns no lease when
there is none (no writer has ever committed there, so there is nothing to
exclude). CommitBatch and PushAll create the folder and lock as before.

Test DirectoryMessagePackBackingStore_Open_CreatesNothing_UntilAWrite:
opening a missing store and a manifest-less .records folder, read-only
and writable, leaves the filesystem unchanged; the first flush creates the
manifest, the lock and one page. With the previous lease code restored
the test fails on the first read-only open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
2ad8dbc9b3 Rebuild the CultMath 0.2.0 DLL from normalized sources
The previous prebuilt DLL/PDB came from LF working-copy files. The PDB embeds
source checksums, so a text=auto checkout (CRLF on Windows) built different
bytes and build-unity-package.ps1 would reject the tracked artifact. Rebuilt
from a clean checkout of the committed sources and re-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:49 +02:00
Meta Vens
d9e469554d State HLSL source compatibility as CultMath's design target
The design doc now names HLSL as the parity target: HLSL-shaped math
compiles as C# under using static CultMath.math, components and swizzles are
writable, comparisons are component-wise, matrices are row-major through mul,
and select uses HLSL's argument order. "Prefer small immutable value types"
becomes plain mutable value types. CultMath keeps its own decisions where
HLSL is silent (safe normalize, float hash, its own Random), and the core
assembly stays engine-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:00 +02:00
Meta Vens
23cc86c4e9 Ship CultMath 0.2.0 to Unity with a UnityEngine conversion bridge
- Bump GameCult.Math and org.gamecult.cultmath to 0.2.0; the CultCache
  Unity tools package now depends on cultmath 0.2.0.
- Rebuild the tracked netstandard2.1 CultMath.dll/pdb with the builder's
  deterministic command; build-unity-package.ps1 verifies it byte-for-byte.
- Add Runtime/UnityBridge (CultMath.UnityBridge asmdef) with ToCultMath /
  ToUnity / ToColor extension methods for Vector2/3/4, Vector2Int/3Int,
  Quaternion and Color. C# only admits conversion operators on the source
  or target type, and the core DLL stays engine-free.
- The builder admits exactly that one bridge assembly: it must reference
  UnityEngine and CultMath.dll, and no core source may be compiled in the
  package.
- Sync the Unity copy of CultMath.hlsl and the third-party notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:00 +02:00
Meta Vens
d514efebba Make CultMath HLSL-shaped: writable components, bool-vector comparisons
HLSL is now CultMath's semantic authority where it defines one.

- float2/3/4, int2, double2/3, bool2 and quaternion are plain structs with
  public mutable fields instead of record structs. Indexers and swizzles
  (float2.yx; float3 xy xz yx yz zy xyz xzy yzx zyx; float4 xy xyz xyw yzx)
  have setters.
- ==, !=, <, >, <=, >= are component-wise and return bool2/3/4. Equals and
  GetHashCode stay value-based so vectors remain dictionary keys.
  quaternion is not an HLSL type and keeps a bool ==.
- Scalar/vector arithmetic and comparisons get explicit overloads on both
  sides instead of leaning on implicit splat.
- New types: bool3, bool4, int3, int4, float2x2, float3x3 (row-major like
  HLSL, _mRC fields, Rotate / RotateX/Y/Z / Euler with math.RotationOrder).
- New intrinsics: mul (matrix-vector both sides, matrix-matrix), any, all,
  select (HLSL argument order), log, vector pow/sqrt/sin/cos/acos/atan2,
  double min/max, int3/int4/bool3/bool4/float2x2/float3x3 constructor
  functions.
- simplex_noise is renamed snoise and gains the Ashima 3D variant; the HLSL
  mirror follows as cultmath_snoise(float2/float3).
- Random gains NextFloat3() and NextFloat3(float3 max) on the same draw order.

Tests pin snoise(float3), float3x3.Euler and float2x2.Rotate to
Unity.Mathematics reference values, and an HLSL-shaped source block compiles
under using static CultMath.math.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:29:59 +02:00
Meta Vens
131c508a73 Fix Cut 6 Studio findings: edit a copy, honest New, contested drawers
Edits: drawers now mutate a MessagePack clone of the selected record.
The cached object changes only through an admitted UpsertAsync; after
any upsert attempt the copy is dropped and re-cloned from the cache, so
a refused upsert leaves memory untouched (covers member Set, list and
dictionary in-place rebuilds). Duplicate uses the same clone helper.

Foldouts restore GUI.changed, so expanding never upserts or dirties.

Extension point: Draw is wrapped; ExitGUIException rethrows, anything
else draws an error row and returns the value unchanged. A type claimed
by two or more drawers is claimed by none; every claimant is logged and
the member draws the red row naming them.

Multi-dimensional arrays (float[,], bool2[,]) draw an error row and are
never flattened into a rank-1 array.

Dictionary keys compare by serialized identity; CultRecordRef<T> keys
compare by key string with null and "" the same empty key. Null,
empty-ref and duplicate keys are refused with a visible warning. Add
picks the first unused record for ref keys and warns when none is left.

Union popup shows "None" for null and never labels a declared subtype
as undeclared.

New creates only a writable single-file store: disabled with Read Only
or Directory toggled, refuses an existing path (file or .records
folder) without closing the open store, and reports Created only after
the file exists on disk.

CultCache.FlushAsync(bool soft) kept: Mimir.CultMeshMedia calls
cache.FlushAsync(soft: true) on a CultCache through a project reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:18:03 +02:00
Meta Vens
8be174b82c Reference CultCache from the Studio at compile time; add drawers
Cut 6. CultCache Studio 1.0.0 -> 1.1.0.

Deleted: the CultCacheBridge string-reflection bridge (type lookups by
name, GetMethod/GetProperty invocation, PullOnOpen, FlushAsync(bool)),
its DescriptorInfo/CatalogMemberInfo/RecordInfo shadows, and the
disabled-text-field fallback in DrawValue. The window calls
CultCacheMessagePack.Create, UpsertAsync(Type, object, key), Remove,
FlushAsync, IsDirty, Registry.AllDescriptors, AllStoredDocuments and
BackingStores directly through GameCult.CultLib.

Window: single-file and directory stores, optional read-only open with
edits disabled and the reason shown, document types from the registry,
records by [CultName] with key fallback including subclasses, Add,
Duplicate (MessagePack round trip), Delete, immediate upsert on edit,
Save = flush, Dirty indicator. An absent [CultGlobal] shows as absent
with an explicit Create; nothing is invented. Opening and closing never
writes.

Drawers (new CultCacheStudioDrawers.cs): dictionaries with recursive
keys and values, abstract/interface members through their MessagePack
[Union] declarations only, CultRecordRef<T> record picker plus raw key,
CultMath float2/3/4, double2/3, int2, bool2, quaternion, rect, Color32,
enums including flags, lists and arrays with add/remove, nested keyed
types, a red error row for unclaimed types, and the
[CultInspectorDrawer(typeof(T))] extension point discovered through
TypeCache (ICultInspectorDrawer). Existing inspector attributes keep
working.

package.json depends on org.gamecult.cultlib 1.0.57 (Cut 7) and
org.gamecult.cultmath 0.1.2. No Unity.Mathematics reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:02:49 +02:00
Meta Vens
47c7111897 Delete the TypeScript re-entry guard; state the store-wait rule once
The AsyncLocalStorage guard refused a store's change handler or timer
writing back to its cache without the store waiting for it, which only
queues, and still missed a wait that escapes the async context. The serial
queue and its own-promise chain stay.

The storePushThatAwaitsItsOwnCacheIsRefusedInsteadOfHanging test is
replaced by storeChangeHandlerWritingBackQueuesAfterTheOperation: a store
push emits an event whose handler puts without being awaited; both writes
land in order and store and cache agree.

The contract's Locking section states the rule for all runtimes: a store
call must not wait for a write to its own cache; unawaited write-backs
queue. Python refuses a same-thread re-entrant mutation; no runtime detects
a wait on another thread or async context. The globals row states the
two-step legacy retirement with compensation, and that a crash or double
failure leaving both records is refused at the next pull.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:43:46 +02:00
Meta Vens
9c3c92634a Require an atomic push_all from every Rust backing store
The default CacheBackingStore::push_all deleted every row and then pushed
one by one, so a failing push left the store emptied or partial while
put_prepared_batch claimed all-or-nothing. The default is deleted:
push_all is a required method documented as one atomic replace, and a
compile_fail doctest pins that a store without it does not compile.
Every in-tree implementor (single-file, both redb stores, the test store,
Epiphany's runtime store) already supplies its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:28:07 +02:00
Meta Vens
4158a38a34 Refuse re-entrant TS cache calls and adopt legacy-key globals on load
The serial chain now advances on its own promise and returns the
operation's promise untouched, so an unawaited failing put is an
unhandled rejection again instead of being swallowed by the chain's
catch.

A queued call started from inside a store call, decoder, accessor or
updater of the same cache's running operation throws at entry instead of
waiting on itself. Detection is an AsyncLocalStorage list of running
operations, so independent concurrent callers are still queued.

A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk, under any keys,
are still refused.

concurrentGlobalPutsCannotBothReachDisk now pauses inside push and fails
when the chain is replaced by Promise.resolve().then(operation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:28:07 +02:00
Meta Vens
9845103085 Record re-entry refusal, legacy-key global loading and atomic push_all in the store contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:27:55 +02:00
Meta Vens
5be87c7696 Refuse re-entrant Python cache mutations and adopt legacy-key globals on load
Attach, pull, registration, writes and deletes raise when started on a
thread already inside a mutation of the same cache (a store call,
decoder, encoder, extractor or updater), instead of running inside it
and corrupting the state being built. Reads stay reentrant. Internal
delegation (put_global, update, delete_global, register_registry,
add_backing_store) goes through private helpers so it is not re-entry.

A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk are still refused.

The lock and unlocked-read comments now state the per-call guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:27:55 +02:00
Meta Vens
15c6a5d513 Serialize the Python cache behind one reentrant lock per instance
The Python cache took no lock, so "refused write changes neither store nor
cache" and the one-home rule held only for one caller at a time. A put paused
between its store push and its apply let a concurrent attach route the type to
a new home (the record stayed in the old store), and let a second global put
reach disk first so cache and disk disagreed.

CultCache now holds a threading.RLock around attach, pull, registration, every
write and delete (globals and update included), name/index resolution and
snapshots, matching the C# per-cache gate and the TS serial queue. Single-dict
reads stay unlocked. No public API change.

Tests: test_concurrent_put_and_attach_cannot_land_record_in_non_home_store and
test_concurrent_global_puts_cannot_both_reach_disk force the interleaving with
a store that pauses inside push; both fail on 4dc7f3a and pass here. The
contract's Python cells drop the one-caller caveat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:03:45 +02:00
Meta Vens
4dc7f3a1fb Record write atomicity and __global__-only globals in the store contract
Adds the row "Refused write changes neither store nor cache" with each
runtime's actual guarantee, including that Python serializes nothing across
threads. Corrects the TypeScript home-move cell (attach runs in the serial
queue), the TypeScript and Python globals cells (any key but __global__ is
refused before a store is touched), the Rust refused-at-load cell (load_soa
covered, load_envelope gone) and the Rust and Python batch descriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:58:18 +02:00
Meta Vens
4ed9871252 Make Rust CultCache batch and SoA loads validate before admitting
put_prepared_batch refuses an empty key or stored_at before any store is
touched. Its one-home check covers the staged records only, and the home store
receives only its own records (those this cache holds for it plus the staged
ones), so a single-home batch is no longer refused because other homes hold
records.

load_soa builds the next view in a local map, refusing an unregistered type or,
once a store is attached, a type with no home store; the view is replaced in one
step, so a refused load leaves the prior view intact.

load_envelope is deleted: it had no callers and admitted records into the view
without a store.

Tests: refuse-at-load and pull start from a non-empty cache and assert prior
state intact; atomic load_soa; single-home batch beside other homes' records;
batch with empty key or stored_at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:57:44 +02:00
Meta Vens
286435d861 Serialize TypeScript CultCache mutations and validate writes whole
A per-cache promise chain now runs addBackingStore, pullAllBackingStores,
registration and every write and delete one at a time, so validation, the
store call and applying to the cache cannot interleave with another mutation.
Those methods return promises; the builder configures its unreleased cache
synchronously. Reads stay synchronous.

put and putEnvelope resolve the home, check the global key and run the name and
index accessors before the store is touched; applying afterwards has no
fallible step. Each held record keeps the lookup values it produced at
admission, so overwrite and delete run no accessor. A global is only stored
under __global__: any other key is refused, and replacing the global writes
__global__ with no delete-then-push. registerDocumentType, registerNameLookup
and registerIndex check schema-id and schema-name conflicts and derive every
lookup before installing anything.

cultmesh-ts awaits registerDocumentType in createNode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:57:01 +02:00
Meta Vens
9288f93c08 Validate a Python CultCache write whole before touching a store
put, put_envelope and put_envelopes now compute the new records' name and
index keys and resolve the home store before any push; applying to the cache
afterwards runs no extractor. Each held record keeps the lookup keys it
produced at admission, so overwrite and delete remove lookups without calling
user extractors. put_envelope and put_envelopes refuse an empty key and a
global under any key but __global__. Per-type dicts are created only after the
store accepts. register_document_type, register_name_lookup and register_index
derive every lookup under the candidate extractors first and install
extractors and lookups together, so a raising extractor installs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:48:59 +02:00
Meta Vens
446122daef Build the whole next state before a pull swaps it in
A pull is all-or-nothing, but TS cleared the cache and then ran user name and
index accessors while re-applying entries, and Python swapped values in and
then rebuilt lookups. An accessor that threw left the cache partly loaded.

TS: the hydrated maps (entries, type entry ids, name and index lookups, global
keys) are one HydratedState. The pull builds a fresh one, running every check
and accessor, and replaces #hydrated in one assignment at the end. The
reset-then-apply sequence, #resetHydratedState, the staged global-key map and
the three per-map getOrCreate helpers are deleted; apply/remove take the
target state. The duplicate-global check now lives only in apply. Name and
index lookup rebuilds build a local map before assigning.

Python: the pull derives names and indexes into local dicts and assigns
values, envelopes, names and indexes together; _rebuild_indexes delegates to
the same pure _derive_lookups.

Rust already stages into a local map and assigns self.entries last, with no
derived lookups; unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:03:30 +02:00
Meta Vens
1f442f3345 Refuse duplicate global records while staging a pull
A load is all-or-nothing. TypeScript checked for a second record of a
global type inside #applyHydratedEntry, which runs after
#resetHydratedState, so a refused pull left the cache cleared and partly
reloaded. The check now runs in the staging pass beside the home check,
before anything is cleared; the apply step's pull-only branch is gone.

Python already validated before swapping, but its check only fired when
the __global__ record arrived second, so a store holding __global__ then
another key of a global type loaded both. Any second record of a global
type is now refused regardless of order.

Rust has no global document types and replaces its entry map only after
staging; it had neither flaw.

pullWithDuplicateGlobalLeavesCacheUnchanged (TS) and
test_pull_with_duplicate_global_leaves_cache_unchanged (Python) fail on
9ab1706 and pass here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:57:09 +02:00
Meta Vens
9ab17061f0 Record home stability and refuse-at-load across CultCache runtimes
The runtime status table now carries separate rows for the rules the
sibling runtimes implement as of this branch: zero stores is in memory
(uniform across all four), a record's home cannot move after admission,
and a record loaded from a store that is not its home is refused. The
attach row states plainly that Rust, TypeScript and Python attach
without reading, so the caller pulls. It also notes that an empty type
list is the generic store everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:51:55 +02:00
Meta Vens
a23a33e184 Keep cultcache-py record homes stable and refuse strays at load
Conform cultcache-py to the store-composition contract, following C#:

- add_backing_store and add_generic_store raise, attaching nothing, when
  the new store would move the home of any document type the cache
  already holds.
- add_backing_store(store, []) attaches the generic store (same as
  TypeScript, Rust and C#'s untyped attach) instead of silently doing
  nothing.
- pull_all_backing_stores stages every store's records and replaces the
  cache's view only when all loaded. A record delivered by a store that is
  not its home raises naming the key and both stores; nothing is admitted
  and no store is written.

The zero-store in-memory test passes on 6bb41f3 as well (Python already
conformed); the other new tests fail there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:50:48 +02:00
Meta Vens
03ff299233 Keep cultcache-rs record homes stable and refuse strays at load
Conform cultcache-rs to the store-composition contract, following C#:

- add_backing_store returns Err, attaching nothing, when the new store
  would move the home of any entry type the cache already holds.
- pull_all_backing_stores loads every store into a staged image and
  replaces the cache's entries only when all loaded. An entry delivered by
  a store that is not its home returns Err naming the key and both
  stores; nothing is admitted and no store is written.
- A cache with zero stores is in memory: put, put_envelope,
  put_raw_envelope, put_prepared_batch and delete no longer error. Once
  any store is attached, a type with no home still errors.

Tests for each rule fail on 6bb41f3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:50:48 +02:00
Meta Vens
6b117def62 Keep cultcache-ts record homes stable and refuse strays at load
Conform cultcache-ts to the store-composition contract, following C#:

- addBackingStore refuses, attaching nothing, when the new store would
  move the home of any record type the cache already holds (for example a
  store routed to "settings" attached after settings:app landed in the
  generic store).
- pullAllBackingStores reads, decodes and home-checks every record before
  replacing the cache's view. A record delivered by a store that is not
  its home throws naming the key and both stores; nothing is admitted and
  no store is written.
- A cache with zero stores is in memory: put, putEnvelope and delete no
  longer throw. Once any store is attached, a type with no home still
  throws.

Tests for each rule fail on 6bb41f3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:50:07 +02:00
Meta Vens
6bb41f39ea Give every cultcache-rs entry type one home store
Cut 5 (Rust). The four mirror loops in put, put_envelope, put_raw_envelope and
delete are deleted, and so is put_prepared_batch's route.len() != 1 branch.
resolve_route_index returns the single home store (the store that claims the
type, else the generic store) and replaces resolve_route_indices.

add_backing_store and add_generic_backing_store return Result and refuse a
second generic store or a type already claimed by another store, attaching
nothing when they refuse. Callers take the Result: the crate's tests, the
cultcache_interop example, cultnet-rs's tests and interop peer example, and
CultMeshNode::create_node in cultmesh-rs.

Tests: second_generic_store_is_rejected, type_claimed_twice_is_rejected;
type_specific_store_routes_before_generic_store stays, now attaching the routed
store before the generic one. No bytes change.

The store-composition contract's runtime table now records Rust, TypeScript
and Python as conforming to one home store per type, and the README routing
section drops its non-conformance note.

Version 0.1.0 -> 0.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:38:41 +02:00
Meta Vens
5967a1f25d Give every cultcache-py document type one home store
Cut 5 (Python). stores_by_type maps each type to one store and generic_stores
becomes a single generic_store. add_backing_store raises on a type already
claimed and add_generic_store on a second generic store; a refused
registration claims nothing.

_stores_for_type and _all_specific_stores are deleted. _store_for_type returns
the home store and raises when stores are attached but none is home to the
type. It returns None only for a cache with no stores, which the contract
keeps as an in-memory cache. put, put_envelope, put_envelopes and delete each
write one store; pull_all_backing_stores pulls each distinct store once.

Tests: test_second_generic_store_rejected, test_type_claimed_twice_rejected,
test_types_route_to_home_store. No bytes change.

Version 0.2.0 -> 0.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:37:31 +02:00
Meta Vens
8dd5a45378 Give every cultcache-ts document type one home store
Cut 5 (TypeScript). The cache no longer mirrors: StoreRoute and its mirrors
list are gone, #resolveRoute returns the single home store (the store that
claims the type, else the generic store), and put, putEnvelope and delete write
exactly one store.

addBackingStore now refuses a second generic store and a type already claimed
by another store, attaching nothing when it refuses, matching C#. No bytes
change; nothing in CultLib attached more than one store.

Tests: rejects a second generic backing store, rejects a type registered to
two stores, routes each type to its home store. The writer-by-reader interop
test now runs the C# write-routed mode and checks that catalog.cc and run.cc
are each a complete v1 snapshot holding only their own record, and that every
reader (TS, Rust, C#, Python) reads the routed catalog store.

Version 0.13.5 -> 0.14.0; cultnet-ts and cultmesh-ts depend on ^0.14.0 so the
workspace link still satisfies their range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:37:31 +02:00
Meta Vens
0408768d35 Give the counter sample integer MessagePack keys
CounterState in samples/eve-browser-network used string [Key]s, which the
registry rejects at registration, so the provider threw on open. Keys are now
0 counterId, 1 count, 2 receipts. The payload becomes a MessagePack array, so
the browser lowerer reads count positionally instead of by field name. The
getting-started chapter carried the same string-key shape and is corrected to
match.

BenchItem needed nothing: a5d1be6 already marked it [MessagePackObject]; the
earlier scan rejection came from a stale benchmark build. After rebuilding,
both types register.

No .cc stores exist under samples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:27:19 +02:00
Meta Vens
5bd4e4ed6e Persist get-only and readonly members the way MessagePack does
The reflective registry now decides persistence from the same two facts
MessagePack 3.1.7's DynamicObjectResolver uses: whether it can assign a keyed
member after construction, and which constructor it picks.

- Every keyed public member is discovered, get-only properties included.
  MessagePack writes them all; the registry used to leave a get-only property
  out of the catalog and schema id even when a constructor filled it.
- MessagePack picks the [SerializationConstructor] if marked, otherwise the
  longest visible constructor whose parameters take the keyed members by
  position (probed: a longer non-matching constructor falls back to a shorter
  one). A member it cannot assign is read back only when its slot is below
  that constructor's parameter count.
- A readonly field is assignable under AllowPrivate (probed: round-trips with
  only a parameterless constructor), and otherwise only through the picked
  constructor. Keyed readonly fields that constructor fills are accepted.
- A keyed member MessagePack writes but never reads back is refused with a
  message naming the constructor position: a get-only property, a readonly
  field without AllowPrivate, or a non-public setter without AllowPrivate,
  when the picked constructor does not fill it (probed: loaded as default).
- An unkeyed public get-only property is refused; MessagePack throws "all
  public members must mark KeyAttribute or IgnoreMemberAttribute". The unkeyed
  message now names both fixes.

Schema ids change for five GameCult.Eve.Surface documents, whose keyed members
are all get-only properties filled by a [SerializationConstructor]:
gamecult.eve.surface, gamecult.eve.asset_catalog,
gamecult.eve.command_invocation, gamecult.eve.command_receipt and
gamecult.eve.provider_advertisement (EveProviderAdvertisementDocument). Their
catalogs gain those members. No .cc store under F:\Projects holds records of
these schemas: the gamecult.eve.provider_advertisement catalog in Gjallar's
scratch smoke stores belongs to GjallarProviderAdvertisementRecord, which has
no get-only members, and every other hit is prose or a git tree listing.

Consumer registration scan (CultLib src/tests/tools/samples, Ymir.Core,
Gjallar, AquaSynth Core/Faust, Mimir): 148 types register; the two rejections
(CounterState string key, BenchItem without [MessagePackObject]) are rejected
on 9139bf3 too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:21:21 +02:00
Meta Vens
9139bf3195 Make registry member visibility follow MessagePack's
The reflective registry now accepts what MessagePack 3.1.7 round-trips for a
[CultDocument] and refuses what it would silently lose or throw on:

- A property is writable when its setter is public, or any setter (private,
  internal, init-only) under AllowPrivate. A public getter with a non-public
  setter on a type without AllowPrivate is refused: MessagePack wrote it and
  loaded null. Private and init-only setters on AllowPrivate types, previously
  over-rejected, are accepted and round-trip.
- A [Key] on a non-public member of a type without AllowPrivate is refused;
  MessagePack skipped it silently.
- A class with no constructor MessagePack can pick is refused (the primary
  constructor case). The check mirrors DynamicObjectResolver as probed: the
  [SerializationConstructor] if marked, else any public (any, under
  AllowPrivate) constructor whose parameters take the keyed members by
  position with an assignable type.

Tests: InternalSetterWithoutAllowPrivate, KeyedNonPublicMemberWithoutAllowPrivate,
AllowPrivatePrimaryConstructor, AllowPrivateSettersRoundTrip (byte-matched
against MessagePackSerializer); InheritedPrivateSetter carries the new message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:03:23 +02:00
Meta Vens
96ee5fdce3 Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.

Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.

The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.

Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
Meta Vens
a5d1be650d Mark every Cult document [MessagePackObject]
PlayerData, CultMeshSimulationFact, the Networking interop peer's
CultNetInteropNote, the cultnet-py benchmark item and the Caching test
documents lacked [MessagePackObject]. Only the generated codec could
serialize them; MessagePackSerializer refuses such types. Non-public test
documents take [MessagePackObject(AllowPrivate = true)], as consumer
internal documents already do, and ConditionalCommitTests.Slow marks its
private backing fields [IgnoreMember].

Same [Key]s, so payloads are unchanged: a pre/post probe over the
Networking assembly and both interop peers showed identical schema id,
content hash and payload bytes for all 9 documents, PlayerData equal once
its Guid.NewGuid() default id is masked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
Meta Vens
30bf7fd267 Make MessagePack [Key] the slot authority and pin generic resolvers
.cc is MessagePack-based, so the core no longer pretends to be
serializer-independent. GameCult.Caching references
MessagePack.Annotations 3.1.7 and reads KeyAttribute and
IgnoreMemberAttribute by type instead of matching their names. The
benchmark's own MessagePack.Annotations reference is dropped; it arrives
through GameCult.Caching.

A generic document's options come from its generic definition's
assembly: Doc<X> uses Doc's declared resolvers, never X's.
GenericDocumentUsesItsOwnAssemblyResolvers compiles GenericHolder<T>
into an assembly whose resolver writes Pair as [B, A] and checks the
payload uses it rather than Pair's own assembly resolver.

Contracts: schema-compatibility states the slot rule and its
rejections; store-composition states the generic resolver rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:06:17 +02:00
Meta Vens
7c4bc2bdc0 Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).

- Accessors are named per document, so documents sharing a [CultName] or
  [CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
  properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
  or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
  declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
  re-keyed override and serializes an override marked [IgnoreMember]).
  An override that changes them is refused in both paths; an override
  that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
  generated codecs and declared resolvers; deleted. CultNetDocumentBinding
  and CultMeshSnapshots route through the untyped path with their registry.

DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Meta Vens
6d7868e0c7 Give the CultCache benchmark the Key attribute it now needs
The benchmark references only GameCult.Caching, which carries no MessagePack
dependency, so the [Key]s BenchItem gained in ea5a07b did not resolve and the
tool stopped building. It references MessagePack.Annotations, which brings the
attribute without MessagePack's analyzer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:22:15 +02:00
Meta Vens
9009aa79bc Generate derived documents' members and require [Key] on every one
The generator walked only members declared on the document class and emitted
a codec only when those were dense, so a derived document's generated
descriptor hashed a different schema id and catalog than the reflective one.
It now discovers members along the base-type chain the way
CultDocumentRegistry reflects them (public instance fields and get/set
properties, inherited included, [IgnoreMember] excluded), and density is
over that full set: RoutingWeapon : RoutingGear gets a codec and the same
SchemaId and catalog (Name@0, Damage@1) from both builds.

Unkeyed members made the two orderings disagree (generator by name, registry
by metadata token). Both implicit-slot paths are deleted: an unkeyed
persisted member of a [CultDocument] type is generator error GCC001, and the
registry throws the same message. The consumer audit found no unkeyed
document with a persisted store.

Tests: GeneratedAndReflectiveDescriptorsAgreeForDerivedDocuments failed on
ea5a07b ("no generated codec for the derived document") and passes through
UpsertWeaponThroughGearHandleReloadsAsWeapon; UnkeyedDocumentMemberIsRejected
drives the generator through Roslyn and compares its diagnostic with the
registry's exception. The registry's reflective BuildDescriptor is internal
and visible to the tests for that comparison. The registry-collision fixture's
emitted field gains its [Key]; schema-compatibility fixture ids are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:19:08 +02:00
Meta Vens
ea5a07b37a Own serialization options by the document's assembly
The static UntrustedData options are gone. An assembly declares its formatter
resolvers with [assembly: CultCacheFormatterResolver(typeof(R))];
CultDocumentMessagePackSerialization.OptionsFor(assembly) composes them ahead
of CultDocumentResolver and StandardResolver, once per assembly, and hands
assemblies that declare nothing the base Options. Generated codecs and the
reflective SerializeUntyped/DeserializeUntyped path both read
OptionsFor(typeof(X).Assembly). No registration call, no mutable options, no
load order.

CultMessagePackSecurity copies UntrustedData and supplies a collision-resistant
comparer over CultRecordRef<T>.Key.Value, so ref-keyed dictionaries
deserialize.

Tests: RefKeyedDictionaryRoundTrips failed on 825b4f7 ("No hash-resistant
equality comparer available"); DeclaredResolverEncodesValueTypeAsPositionalArray
and UnknownAssemblyGetsBaseOptions did not compile there (new API);
InteropNoteBytesUnchanged is a pin whose payload constant was captured on
825b4f7. BenchItem, the one unkeyed [CultDocument] in any consumer (in-memory
benchmark, no store), gains [Key]s in its existing slot order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:12:53 +02:00
Meta Vens
6fcc2a7704 Stop MessagePack's source generator flowing to consumers
A direct MessagePackAnalyzer reference with PrivateAssets="all" in
Caching.MessagePack and Networking keeps MessagePack 3's generator out of
consumer builds, where it emitted a reference to a nonexistent
GeneratedMessagePackResolver formatter for Dictionary<CultRecordRef<T>, float>
(CS0426). CultLib composes StandardResolver itself and never registers a
generated resolver.

tests/GameCult.Caching.ConsumerBuild is RefKeyedDictionaryCompilesInAConsumer:
its build is the test. On 825b4f7 it failed with CS0426; now it builds and
obj has no MessagePack.SourceGenerator output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:09:16 +02:00
Meta Vens
825b4f7d26 Mirror snapshots carry the cache Sequence they reflect
ObserveAsync, ReactiveAsync and RefreshAsync read a snapshot without a
Sequence, so an older change admitted before the read but delivered after it
overwrote the newer value.

CultCache.GetWithSequence(key) returns the document and the cache's Sequence,
read together under the gate. A cache-backed CultMesh handle passes it to its
mirrors; each mirror subscribes first, then adopts the sequenced read under its
own gate and takes that Sequence as applied, so a straggler at or below it
drops. RefreshAsync on both mirrors uses the same adoption path. Public Mesh
surface unchanged. The read is non-generic because the Mesh route needs the
untyped document for same-schema conversion.

Contract: only Watch/WatchRecord changes carry Sequence; OnUpdate-derived
CultNet streams have no ordering or stale protection; schema-alias handles and
removals are not stale-protected; the sequence constructor argument changed
CultCacheDocumentChange<T>'s binary signature.

Tests: ObserveAfterNewerSnapshotIgnoresStraggler and
RefreshIgnoresStragglerOlderThanSnapshot (observed and reactive cases) fail on
4562340; SequencedReadReflectsEveryChangeAtOrBelowItsSequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:02:49 +02:00
Meta Vens
456234018f Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.

The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.

Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.

Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.

Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
Meta Vens
bcae4839d0 Deliver each cache's changes in save order
The outermost hold takes a ticket under the gate and, after releasing it,
waits on a per-cache delivery gate for its turn, delivers, then advances
the turn. Delivery order across threads equals admission order. A hold
closed while its thread is delivering queues its ticket behind that
delivery on the same thread instead of waiting, so an observer writing
the cache cannot deadlock against a writer: a waiting thread holds no gate
and no turn, and a turn holder never waits.

Hand-over-hand from the gate to a delivery lock was rejected: a writer
blocked on the delivery lock while holding the gate deadlocks with an
observer that writes the same cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:09:23 +02:00
Meta Vens
b653f68f81 Route every admitting gate entry through a hold
FlushAllBackingStores and Dispose take the gate through Held, so a store
method that loads can never reach Admit under a plain lock; Admit outside
a hold throws a clear InvalidOperationException. Entering a cache's hold
inside another cache's hold throws. PullAllBackingStoresAsync pulls every
store even when an OnUpdate handler throws, then rethrows. The dead Try
around Subject.OnNext is gone: R3 never throws there, so only OnUpdate
exceptions reach the caller. Repull tests wait for the writer to start
before their bounded wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:01:36 +02:00
Meta Vens
544c087cf1 Publish each hold's own changes to its own caller
The cache-wide unpublished list let any thread drain and deliver another
thread's changes: a throwing observer dropped the rest of the queue and threw
into an unrelated writer, writes returned before their change was published,
and a third-party load could wait indefinitely.

Every admission now runs in a hold (Held). The outermost hold on a thread
collects exactly the changes it admits and publishes them after it leaves the
gate, before it returns, on its caller's thread; nested holds append to it.
Every change is delivered even when an observer throws; the first exception
(AggregateException for several) is then rethrown, and a failing body's own
exception wins over observer exceptions.

Stores take the hold instead of locking a shared Gate object, so a direct
PullAll on an attached store publishes its own load and the empty follow-up
Loaded hand-over is gone. Gate is replaced by protected Held.

Tests: OwnChangeIsPublishedBeforeWriteReturns and
ThrowingObserverDoesNotLoseOtherChanges fail on 4eacd87 and pass here. The
repull tests now record a write that reaches the gate while the pull is paused
instead of inferring readiness from thread state; both fail when the pull does
not hold the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:46:18 +02:00
Meta Vens
4eacd876bf Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.

A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.

Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.

Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.

Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00
Meta Vens
5aad137e61 Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.

An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.

Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.

Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00
Meta Vens
2a44209510 Refuse a key in two stores, stop reporting reader contention, prove lock waits and page order
F2: Validate refuses a write or load that would give a key already held by
one home store a second store; the message names the key and both stores and
nothing lands in either store or the cache.
F3: TryCommit enters the cache gate normally; only the store lock attempt is
non-blocking, so Contended means another writer holds the store.
F4: PushAll and Commit are shown to wait on a held <path>.lock; the race test
asserts mismatches occurred and that Task.WaitAll finished in time.
F5: a failed page write leaves the manifest bytes and the previous generation
intact.

Lock order (F1) and commit merge semantics (F6) are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:21:19 +02:00
Meta Vens
03a71ebd66 Pin CultNet publish-after-commit, add write-routed interop, update contract status
- NetworkingTests.CultNetPutInsideCommitPublishesOnlyAfterCommit: a record
  staged in a batch reaches no CultNet observer before the store commits; a
  put made while staging is its own write. Chosen over refusing CultNet writes
  inside a batch, which would need ambient "inside a batch" state.
- Interop peer mode write-routed <catalog.cc> <run.cc>: cultcache.interop-note
  routes to the catalog file, cultcache.interop-run-note to the run file, one
  record each.
- store-composition contract: the C# column reads implemented for routing,
  attach, schema, lookups, globals, batch and conditional commit; overwriting
  a store is open, remove, upsert, flush. persistence-format: C# implements
  batch and conditional commit.
- tests README lists the new suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:58:00 +02:00
Meta Vens
245eef6fb7 Restore directory store durability coverage with real file locks
Cut 2 deleted the stage probes and the tests that injected faults through them.
DirectoryStoreDurabilityTests takes the faults from held files instead:
FailedManifestReplaceKeepsPreviousGeneration,
SameStoredAtDifferentPayloadWritesADistinctPage, PullAllWaitsForHeldCommitLease,
MutationBlockedBehindFlushStaysDirty, FlushNeverOpensUnchangedPages,
MissingManifestIgnoresOrphanPagesAndFlushDeletesThem,
TamperedPageFailsLoadNamingPage.

The tampered page surfaces as an AggregateException from the parallel page
load whose message names the page; the test asserts the page is named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:58:00 +02:00
Meta Vens
a06d3f71f9 Prove conditional commit on single-file and directory stores
ConditionalCommitTests: StaleExpectFailsWithNothingChanged, ExpectNullIsCreateOnce,
PerEntryCommitSurvivesUnrelatedWrite, ExpectUnchangedFailsOnUnrelatedInsert,
TwoWritersRacingExactlyOneWins, TryCommitReportsContended,
PlainFlushNeverInterleaves, StoredAtIsStrictlyIncreasingPerKey,
DirtyStoreRefusesConditionalCommit, CrossStoreConditionalBatchThrowsBeforeAnyStore,
DirectoryStoreHonorsConditions (the four condition tests on a directory store).

The race is in-process: two store instances on one file, two threads, the
final count exactly 2 + 2N. The lock is a FileShare.None handle, which excludes
a second handle in this process exactly as in another, so the cross-process
TwoProcessesRacingExactlyOneWins and its console project are not added. Each
writer's mismatch is scripted before the free race, so "each writer reports at
least one mismatch" does not depend on scheduling.

PlainFlushNeverInterleaves counts an open that loses to a replace in flight as
no read; every read that gets bytes must deserialize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:59 +02:00
Meta Vens
ffdecf7e26 Turn the store-routing tests green and cover routing, batches and loads
Cut 1's fixtures are rewritten as the cut map names: LoadingNeverWritesASecondStore
attaches its second store with a home type, and the global fixture is a real
[CultGlobal] type instead of a reflected backing field.

New: RoutedStoresWriteTheSameBytesAsSingleStores, CatalogRecordNeverLandsInRunStore,
AttachAfterDirtyThrows, SecondUntypedStoreThrows, DuplicateHomeTypeThrows,
WriteWithoutHomeThrows, ForeignRecordIsRefusedOnLoad,
LateRouteOverAdmittedTypeThrows, ReadOnlyStoreRefusesWrites,
ReadOnlyStoreIsNotFlushed, GlobalSingletonIsEnforced,
AmbiguousAssignableLookupThrows, BatchIsAllOrNothingOnStoreFailure,
BatchAcrossTwoHomesThrows, BatchObserversSeeOnlyCommittedRecords,
BatchReadsSeeCommittedStateOnly, SingleUpsertAndBatchShareOneAdmissionPath,
PullAllAdmissionFailureLeavesStoreAndCacheConsistent,
StoreAttachedToSecondCacheThrows.

Two readings where the map could not be followed literally:
- LateRouteOverAdmittedTypeThrows: "the reverse order succeeds" cannot hold
  with the same untyped file holding a Note, because a Note loaded from the
  untyped store when a Note route exists is a foreign record. The reverse case
  puts the Note in the routed store and a RoutingOther in the untyped one.
- BatchIsAllOrNothingOnStoreFailure: the fault is the store file held open
  without delete sharing, so the atomic replace fails, instead of a PushAll
  override; the single-file commit no longer routes through PushAll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Meta Vens
a3cc394241 Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.

Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
  constructor, the initializeGlobals plumbing in CultCacheMessagePack,
  ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
  existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
  CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
  CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
  gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
  catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
  so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
  called it by reflection and now calls Remove(key)

Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
  already attached, one untyped store, no claimed home type, no admitted
  record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
  (home, read-only, loaded-from-home, global singleton), lands durably, then
  applies and publishes; a pull hands its whole delta over in one call, so a
  refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
  CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
  candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
  CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
  sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
  evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
  store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
  it replaces

Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
Meta Vens
14d7c64774 Mark pre-Cut 2 store fixtures binary
The repo-wide text=auto with core.autocrlf=true would rewrite an LF byte in
a content-addressed page on checkout and break its SHA-256 identity.
Committed blobs already match the bytes produced at 0db1fe5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:17:49 +02:00
Meta Vens
997f3997e6 Exercise CultCache.TryGet<T> again in the OpenAsync durability test
AquaSynth calls TryGet<T>; cover both the hit and the miss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:17:14 +02:00
Meta Vens
b26c98f185 Note that CultManagedDocument is parked with SoA; state the page load rule
The parked/cultcache-soa tag message says the managed document was deleted
separately. parked-features.md now supersedes that; the tag is unchanged.
The directory store gains its design statement and a comment that only
manifest-named pages are loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:17:14 +02:00
Meta Vens
0ab5564688 Drop the unused GameCult.Logging reference from GameCult.Caching
No Caching source uses it. Networking, the only CultLib user, references
Logging directly; no external consumer (Aquarium, Ymir, Mimir, AquaSynth,
Gjallar, Delvehold) uses the GameCult.Logging namespace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:17:14 +02:00
Meta Vens
b6905c2c1c Pin stores written before Cut 2 with checked-in fixtures
Fixtures were produced by CultLib 0db1fe5 from a temporary worktree: a v4
directory store and a v1 single-file store holding the same three records.
Tests reopen both and assert every value, and assert the directory store
refuses v1, v1.directory, v2 and v3 manifests without touching the bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:17:14 +02:00
Meta Vens
946e8fcc2d Restore CultCache.TryGet<T>: the audit missed AquaSynth's call
The Cut 2 subtraction audit listed TryGet<T> as unused, but
AquaSynth\src\AquaSynth.Core\CultCachePatchDocument.cs:104 calls
cache.TryGet(key, out CultCachePatchDocument? document). The map's grep
matched only TryGetByName/TryGetByIndex, so the call was missed and AquaSynth.Core,
AquaSynth.Faust, AquaSynth.Dsl.Tests and AquaSynthDaemon stopped compiling
against 85f2089. The method returns with the signature it had at e9b91c1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:00:36 +02:00
Meta Vens
bb10c8241b Retire the last test fixture for deleted surface; update the tests README
SchemaStampedTestEntry was only used by the v1 missing-catalog load test
deleted with the legacy directory formats. The Caching tests README now
describes what the suite covers after the subtraction, including the ambient
transaction tests kept until Cut 3 and the red store-routing contract tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:57:07 +02:00
Meta Vens
60cbfdde6d Strip doc comments from the Caching projects
Every /// line in GameCult.Caching and GameCult.Caching.MessagePack goes; they
restated member names. Three blocks that state a rule stay as // comments:
MaterializeMissingGlobals runs after hydration so persisted globals win,
ExecuteTransactionAsync commits durably before the cache and observers
advance, and CommitBatch implementations restore their staged view on
failure. Both projects add NoWarn CS1591, since GenerateDocumentationFile is
on in src/Directory.Build.props.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:54:43 +02:00
Meta Vens
360dd0ae2f Delete the standalone schema catalog serializers
SerializeSchemaCatalog and DeserializeSchemaCatalog had no consumer outside
the snapshot round-trip test. Catalog entries still serialize inside
SerializeSnapshot/DeserializeSnapshot through the same per-entry
reader and writer, and the round-trip test now checks the catalog through the
snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:51:35 +02:00
Meta Vens
900e148d8a Read only v4 directory stores; delete probes and selective hydration
DirectoryMessagePackBackingStore loses HydrationFilter, FlushStageProbe,
ReadStageProbe and their stage constants, ReadPersistedGeneration,
PullSelected, and every legacy path: the v2 directory-indexed and v3
immutable-pages formats, v1 inline records and split pages, the index
upgrade on flush, and the legacy page paths. CacheBackingStore.PullSelected
and CultPersistedRecordMetadata go with them.

A missing manifest is an empty store. An existing manifest whose
FormatVersion is not cultcache.store.v4.directory-content-addressed-pages
throws "Directory store {path} is {format}; only {v4} is readable." A clean
flush writes nothing, since there is no legacy index left to upgrade.

Tests deleted with the surface they injected through: the v1 missing-catalog
load, pages-before-manifest, fault-before-manifest (4 cases), same-timestamp
page, mutation-during-flush and reader-lease tests. The orphan-page test now
checks through a fresh store pull instead of ReadPersistedGeneration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:50:08 +02:00
Meta Vens
59410fc474 Delete unused CultCacheOpenOptions callbacks and directory overrides
ConfigureCache, ConfigureStore, DirectoryStorePath, DirectoryStoreHydrationFilter
and ConfigureDirectoryStore had no consumer outside tests. CreateCore attaches
the directory store at its default record path, or the single-file store,
with no customization hooks. OpenAsync keeps its behavior (pull, then
materialize missing globals); PullOnOpen stays until Cut 3.

The indexed-filter test loses its selective-open half and keeps the v4
manifest assertions: the hot index carries only the SHA-256 page identity and
both records reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:45:42 +02:00
Meta Vens
85f2089f0e Delete CultCache wrappers and unused members
Removes FlushBackingStore, both PrepareForReloadOrShutdown overloads,
PullBackingStoreRecordsAsync, TryGet<T>, TryGetByName, TryGetByIndex,
Resolve<T>, the cache and store Logger, and LastSuccessfulFlushAtUtc on the
cache and stores. GetStoredDocuments<T> folds into GetAll<T>.

The soft flag goes from FlushAllBackingStores, ExecuteTransactionAsync,
CommitBatch and PushAll. It stays on FlushAsync(bool) because
CultCacheStudioWindow reflects on that signature until Cut 6.
CultMesh.FlushAsync and CultNetLocal.FlushAsync keep their parameter and
stop forwarding it.

The store's EntryAdded/EntryUpdated/EntryDeleted subjects become
protected internal Loaded/Unloaded callbacks set by the cache at attach.
AddStoredDocumentInternal is synchronous, so the GetAwaiter().GetResult()
bridges on the store subscriptions and the global materializer are gone.

Tests: drop the LastSuccessfulFlushAtUtc asserts; OpenAsync and GetAll tests
read through Get/GetAll; the persisted-global test proves the durable global
loads without staging a default instead of going through selective hydration;
the lease-before-observers test observes through CultCache.Watch; the
PullSelected selective-hydration test is deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:44:11 +02:00
Meta Vens
1982d73e44 Name the managed document in the parked SoA entry
CultManagedDocument<T> and its two Document<T> openers came in with the SoA
storage (5428870) and are preserved at the same parked/cultcache-soa tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:41:55 +02:00
Meta Vens
88e1c8e157 Delete CultNetDatabase's transaction wrapper
ExecuteTransactionAsync (both overloads), RequireTransactionsForAuthoritativeWrites,
EnsureAuthoritativeTransaction and the AfterCommit deferral had no consumer
outside CultNetDatabase_RequiredTransaction_DefersRawPutPublicationUntilCommit,
deleted with them. Writes publish immediately, as they already did outside a
transaction. The cache's own transaction machinery stays until Cut 3 replaces
it with the explicit batch commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:35:56 +02:00
Meta Vens
d26f357f3e Delete CultManagedDocument and both Document<T> openers
CultManagedDocument<T>, CultCache.Document<T> and CultNetDatabase.Document<T>
had no consumer outside the tests removed with the SoA table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:23:55 +02:00
Meta Vens
fe7e5fbe37 Delete the parked CultCache SoA table
Removes CultSoaTable, CultSoaColumn, CultCacheSoaStore, CultCacheSoaTypeTable,
CultCacheSoaMember, CultCache.Soa<T>, the _soa field and its four hooks, and
the two tests that exercised them (with their TransformEntry and
MeshManagedPlayer fixtures). The last working form is at
parked/cultcache-soa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:22:54 +02:00
Meta Vens
d904960610 Park the CultCache SoA table at parked/cultcache-soa
The SoA table was built for ECS-style structure-of-arrays columns over
cached documents and has no consumer outside tests. It is tagged before
removal; docs/parked-features.md names the tag, span, and restore path, and
the runtime parity scope says the table is parked rather than wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:21:25 +02:00
Meta Vens
0db1fe5448 Apply Soul fixes to the store composition docs and routing tests
Conditional commit, not plain flush, is what protects against unseen data.
C# batch and conditional commit are specified, not implemented. Home-type
claims use exact equality. TypeScript attach does not read. CultMath has no
encoding. Reflective and generated descriptors differ until Cut 4. Routing
tests now check the inherited Name, the global flag precondition, and that a
refused write publishes no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:13:20 +02:00
Meta Vens
d81112fe44 Add red store-routing tests
Eight tests against today's API, each red for the behavior the store
composition contract removes: attach pushes into a second store, the generic
parameter chooses schema, exact-type name/global/watch lookups, invented
globals destroying a seeded file, and a refused push leaving the document in
memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:01:32 +02:00
Meta Vens
486fe560af Correct stale store-routing, SoA, and generator docs
The README and the Rust README described mirrors as C# behavior; the parity
scope claimed the C# SoA table; the generator README described DatabaseEntry
formatters that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:01:32 +02:00
Meta Vens
37f73c4e38 Write the CultCache store composition contract
One home store per document type, attach is hydration, loading never writes,
read-only stores, runtime type decides schema, assignable lookups, globals
never invented, explicit-batch and conditional commit (a plain flush is
last-writer-wins), per-assembly serialization options, and positional-array
value-type encoding. The persistence-format transaction section becomes the
explicit-batch shape; schema compatibility says which schema a record carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:01:32 +02:00
Meta Vens
e9b91c1f50 Preserve public trust-anchor parent permissions
enroll_service_identity_at and export_service_identity_trust_anchor
both routed through prepare_parent, which chmods the target directory
to 0700 on unix. That is correct for the private enrollment path but
wrong for exporting a public trust anchor: Idunn's provisioning and
control-plane code export the public anchor into a directory another
service already owns and reads from (Idunn/src/provisioning.rs,
Idunn/src/control_plane.rs), and forcibly narrowing that directory's
permissions on every export would lock out the legitimate reader
instead of protecting anything.

Split the helper: ensure_parent only creates the directory, and the
renamed prepare_private_parent (used solely by enrollment) also locks
it down. Ported from GameCult/cultnet-rs commit 69f2498 ("Preserve
public anchor parent permissions"), which fixed the same bug before
that standalone repo was superseded by this package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:45:40 +02:00
Meta Vens
e9e9c60b0f REC-102: cut stale generated mirrors and a dead maintenance receipt
Deletes four confirmed-dead tracked files:
- CultLib.txt, src/GameCult.Caching/GameCult.Caching.txt,
  src/GameCult.Unity/Assets/UI/UI.txt: concatenated snapshot copies of live
  source, carrying stale E: drive provenance and obsolete dependency
  versions. Live source under src/ remains the owner.
- tests/GameCult.Networking.Tests/lcov.info: tracked coverage output.
  .vscode/tasks.json:14 still generates it fresh via
  "/p:CoverletOutput=./lcov.info" on every coverage run, so tracking the
  stale committed copy in git is the liability, not the generation step.
- .voidbot/status/void-memory-maintenance.json: a one-time "starting"
  receipt (added whole in 4813570, never touched again) naming a dead
  E:\Projects\CultLib path and an unimplemented repo_face_heartbeat
  launcher. VoidBot's actual heartbeat state lives at
  .voidbot/status/repo-face-heartbeats.{cc,json} (see
  packages/config/src/index.ts REPO_FACE_HEARTBEAT_STATE_PATH) — a
  different file entirely; nothing reads this one.

Kill attempts:
- git grep -n for each of "CultLib\.txt", "GameCult\.Caching\.txt",
  "UI\.txt", "lcov\.info" across origin/main, excluding each file's own
  path: only .voidbot birth/distiller snapshots (auto-generated onboarding
  file-listing catalogs, not functional readers) and the one real lcov.info
  producer in .vscode/tasks.json.
- rg across all of F:/Projects (excluding target/node_modules/.git):
  gamecult-site's docs/research/gamecult-org-repo-ledger.json lists
  "CultLib.txt" as a filename in a static historical repo-file inventory
  (not a functional consumer); Aetheria/docs/cultcache-migration-cut.md
  cites GameCult.Caching.txt:157 as an evidence line number in a
  migration-history note (stale citation after this cut, not a functional
  break — flagged for the record, not fixed here since that doc is out of
  scope).
- voidbot MCP search for void-memory-maintenance.json / repo_face_heartbeat
  found only the unrelated repo-face-heartbeats state path, confirming no
  reader.

Collision check: git diff --stat origin/main against each of the other 4
CultLib worktrees (codex/recover-aetheria-authority,
codex/delvehold-isosurface, claude/media-stream-catalog, and the two
detached-HEAD worktrees, both confirmed ancestors of origin/main) over
these five paths: all empty, no in-flight work touches them.

REC-102

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:10:10 +02:00
Meta Vens
c2a9a6e526 Windows atomic replace waits out a transient reader
Some checks failed
Publish packages / @gamecult/cultcache-ts to npm (push) Has been skipped
Publish packages / cultcache-py to PyPI (push) Has been cancelled
Publish packages / cultmesh-py to PyPI (push) Has been cancelled
Publish packages / cultnet-py to PyPI (push) Has been cancelled
A concurrent reader of the store's destination file (another process
opening it, an indexer, an antivirus scan) makes MoveFileExW fail with a
sharing or access error for as long as it holds the handle. That is
transient by construction; treating the first refusal as the outcome
killed Muninn serve on 2026-09-11 the moment a status CLI overlapped a
write. The replace retries such errors for up to half a second. The test
holds the destination open from another thread and expects the replace to
outlast it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:44:17 +02:00
Meta Vens
47b9fc4ee3 A reader returns "nothing" only when nothing is waiting
Both RUDP socket readers returned None after consuming a packet that carried
no frame or event for the caller: an acknowledgement, a ping, a fragment
that completed nothing yet. A caller that polls once per send therefore read
one incoming datagram per outgoing one, and fell behind its peer's
acknowledgements as soon as a reliable channel shared the session; every
unacknowledged packet was then resent, and the resends starved the sends.
Measured on the media path 2026-09-10: 7 video frames in 9 s with reliable
audio on, 266 without. Readers now consume no-event packets and keep
reading; None means the socket is empty.

The client no longer acknowledges unreliable frames. They carry no sequence,
so the ACK told the sender nothing and cost it one datagram to read for
every one it sent.

The fragment-set bound test now asserts eviction, which is what the bound
has done since the fuse was cut earlier today; the old assertion was hiding
behind a truncated test run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 01:21:37 +02:00
Meta Vens
c84cb2e862 The consumer dials the producer
A media stream advertisement now carries `media_endpoint`, where the
producer serves it from; the request no longer carries a consumer endpoint.
The producer listens and the consumer connects, the same way as every other
client/server pair on the mesh, so a viewer's host admits nothing inbound
and needs no firewall rule or port forward. Unshipped contract, keys
renumbered in place.

Audio gets its own channel ("audio": reliable, unordered, media expiry) so
one session can carry lossy video under parity and reliable audio together,
which is what lets the producer serve both to one dialled-in consumer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:50:20 +02:00
Meta Vens
61b7bf0e24 Say how Odin admits catalog records, and what it refuses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:24:04 +02:00
Meta Vens
780e1a48cd Advertise and request media streams without naming a producer
Two state records beside the media wire records, travelling as CultMesh
documents through Odin rather than on the media channel:

- gamecult.media_stream_advertisement.v1: a stream a producer can serve
  on request — its video and audio sources, the codecs it can encode,
  audio sample rate and channels, defaults, and the connection id it
  dials a receiver with. Keyed by stream_id. A consumer's picker is built
  from these.
- gamecult.media_stream_request.v1: a consumer asking a producer to start
  or stop serving a stream to a receiver endpoint, with sources, codecs,
  bitrate and latency budget. Keyed by stream_id:receiver_id so a newer
  request supersedes; the producer answers on the same key by rewriting
  state and detail.

Until now the only way to pick a stream was Muninn's own
muninn.obs_stream_catalog and muninn.capture_stream_command, which live
in odin-core and bind every consumer to one producer. Odin persists any
well-formed document, so nothing there has to change to carry these.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:07:39 +02:00
Meta Vens
4160ab560b A repair request is not a keyframe request
The builder forced requested_keyframe whenever chunk keys were missing.
A producer answers each new keyframe request with an IDR, so every
repair request would have cost a keyframe — the thing a repair request
exists to avoid. The old C++ receiver sent repairs with the flag off;
this builder had never been driven by a real receiver. A whole missing
frame still implies one, since the repair cache holds chunks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:35:04 +02:00
Meta Vens
34c8ea79b9 Build receiver feedback where both ends can see the rules
build_receiver_feedback normalises the record a receiver sends back —
damage lists sorted and unique, chunk keys well-formed, a keyframe
requested whenever something is missing — and until now lived in Muninn,
the one end that never sends it. Ratatoskr sends it; Muninn's sender
tests construct it to exercise the repair cache. A builder both ends
call belongs beside the record it builds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 21:33:06 +02:00
Meta Vens
89687fceb4 Let the browser sample read the provider's advertisement
Eve refuses to build a command intent for a surface that has not
advertised a command boundary and receipt schema, and the intent it
builds now carries the operation under operation.operationId. The
browser sample did neither: the click was refused inside the page and
the checkpoint saw only a 30 s timeout on the receipt.

The C# provider now publishes its gamecult.eve.provider_advertisement.v1
document with the surface's worldInteraction, and the page leases that
and hands it to the lowering as `provider`. The provider owns the
declaration; the page reads it rather than restating it. The command
sink reads intent.operation.operationId.

The checkpoint now forwards the page's console and pageerror output.
"CultMesh browser operation is required." took one run to find with it
and had been a bare timeout without it.

  scripts/verify-eve-getting-started.ps1 -EveRoot ..\Eve (Eve main)
  -> Eve getting-started verification passed: clean .NET and TypeScript
     artifacts, DOM and headless convergence, real Chromium and C#
     clients, Odin discovery, authority-bound routes, receipts,
     persistence, and route replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
f94f5581b0 Let the verse catalog schema say what the reference emits
The C# reference publishes authorityRoutes with certificate: null for
loopback routes, and its own verifier treats null as "no certificate" in
local development. The TypeScript JSON schema only allowed the object,
so the browser client rejected every catalog the getting-started
provider served: "certificate: must be object", Odin could not resolve
the Verse, and the real browser/C# checkpoint has failed since f622ee1
(2026-08-18), behind the sample failure that hid it. TypeScript
consumers already guard route.certificate; the schema now accepts null.

The package-closure smoke also still spelled the browser identity with
providerId; it is authorityRuntimeId since c194bd4.

  npm test --workspace packages/cultnet-ts -> 54 pass
  npm run test:pack:ts -> passed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
672235e8f3 Ignore Python package build output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
830a5d718f Make the Eve onboarding checkpoint run against the contract that exists
The portability workflow has been red since 2026-08-22. Three things,
each hidden behind the one before it:

- Eve started refusing to build a command intent for a surface that has
  not advertised a command boundary and receipt schema (Eve 4338a42,
  2026-08-22). The two-runtime sample never advertised one. It now passes
  a provider advertisement with worldInteraction for its surface, which is
  the declaration the lowering was right to demand.
- The sample and the package-closure script still imported "cultcache-ts";
  the package became @gamecult/cultcache-ts on 2026-09-04 (8cb3b72) and
  CI never got far enough to say so. Two READMEs carried the same import.
- The job pinned Eve to codex/eve-browser-host-isolation, a branch that was
  never merged and stopped moving on 2026-08-25. The sample is written
  against Eve main and passes there; the pin now says so.

The interop workflow's Python step ran one test file by path; it now
discovers the three split suites.

  scripts/verify-eve-two-runtime-sample.ps1 -EveRoot F:\Projects\Eve
  (Eve main ccc3ffe) -> browserCount 1, headlessCount 1, restartCount 1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
6d98b03471 Drop the license classifier setuptools now refuses
setuptools >= 77 rejects "License :: OSI Approved :: MIT License" once a
PEP 639 license expression is set, and `pip install -e` builds with the
newest setuptools. That has failed both CI workflows at the install step
since 2026-09-04. `license = "MIT"` is the expression; the classifier
said the same thing and is gone from all three Python manifests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
95493862bd Split the Python runtime into cultcache-py, cultnet-py, cultmesh-py
One distribution named cultcache-py carried three libraries. Rust and
TypeScript layer them as cultcache < cultnet < cultmesh; Python now does
the same, one package per layer, module names unchanged so every
`from cultmesh_py import ...` keeps working.

Two imports ran against that layering and are resolved by moving code to
where the dependency direction says it lives, not by adding a shim:

- cultnet_py.interop_peer imported the Verse/peer catalog, authority
  lease, stream, and simulation-fact contracts from cultmesh_py. They are
  wire contracts a bare peer has to speak without owning a node, which is
  why cultnet-rs and cultnet-ts keep them in the net layer. They now live
  in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them.
- cultcache_py.benchmark imported cultnet_py. It measures framed CultNet
  parse beside cache paths, so it and compare_csharp move to cultnet_py.
  verify checks all three packages and moves to cultmesh_py.

The single 6,693-line test module is split by what each test touches: a
test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in
cultnet-py, else cultcache-py. Each suite runs with only its layer and the
layers below on the path, so a test that reaches up fails to import
rather than quietly binding the packages back together. 11 / 53 / 78 =
the same 142.

Workflows install all three; the publish job is one matrix over the
three packages keyed by tag prefix. The TS test harnesses put all three
src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no
longer contains cultnet_py or cultmesh_py; the new packages start at
0.1.0. Nothing was ever published, so no PyPI consumer moves.

  cultcache-py  11 tests, path: cultcache-py/src
  cultnet-py    53 tests, path: + cultnet-py/src
  cultmesh-py   78 tests, path: + cultmesh-py/src
  python -m cultmesh_py.verify -> ok, all five checks
  cultnet_py.interop_peer imports with cultmesh_py absent

Not run here: the cultnet-ts interop ring and cultcache-ts store matrix
need dotnet/kotlin peers; their PYTHONPATH change is four lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:10:47 +02:00
Meta Vens
a8aeddacab Let the media probe fragment on demand
A seventh argument sets max_fragment_bytes on the sender. Without it a
3,392-byte payload leaves loopback as one datagram, and a matrix that
claims to measure fragmentation measures nothing: the impair proxy's
received count equalled the payload count in every cell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:59:48 +02:00
Meta Vens
66f05e161a Expose fragment-set evictions in transport stats
A receiver that survives loss by discarding stranded sets has to say so, or
the drop is invisible and the next diagnosis starts from "delivered less
than sent" again. CultNetTransportStats now carries fragment_sets_evicted,
summed across hub peers like reliable_packets_expired, and the media
delivery probe prints it beside delivered_pct.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:59:48 +02:00
Meta Vens
d3bc6e6604 Keep receiving when fragment sets go stale
A fragment set that lost one fragment was never reclaimed: the buffer map
only shrank on successful reassembly or a payload-size violation. Once 64
sets were stranded, every new fragmented payload returned an error and the
receiver's loop terminated. Any nonzero loss on any fragmenting channel was
therefore a countdown, not a threshold: 3 fragments at 3% loss killed the
receiver at ~700 payloads, 1% at ~2,100. Found by the media load test.

When the bound is hit, evict the set untouched longest and count it via
fragment_sets_evicted(). A stranded set's siblings were delivered long ago;
dropping it is the right call and it was already lost.

Also stop saturating the fragment id. saturating_add parked every set after
the 65,535th on one shared id, and the reassembler stitched them together.
That is the July 2026 eight-minute failure; the id now wraps to 1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:59:48 +02:00
Meta Vens
b6b1d9c660 Bound the Python fragment reassembly map
cultcache-py removed a fragment buffer only on successful reassembly, the
same shape cultnet-ts had: a set that loses one fragment is kept for the
life of the session and the map grows under any loss on a fragmenting
channel. Cap at 64 to match the other runtimes, evict the oldest stranded
set (dict insertion order) rather than refuse the payload, and expose
fragment_sets_evicted so the drop is observable.

The fragment id already wrapped correctly here; only the Rust allocator
saturated.

  PYTHONPATH=src python -m unittest tests.test_cultcache -> 142 run, 1
  pre-existing error (interop fixture path is cwd-relative, same on main)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:42:17 +02:00
Meta Vens
2041a0e0ed Bound the TypeScript fragment reassembly map
cultnet-ts removed a fragment buffer only on successful reassembly, with
no cap, no eviction and no timeout. A fragment set that loses one
fragment was stranded for the life of the session, so the map grew
without bound under any loss on a fragmenting channel. cultnet-rs at
least fails closed at 64 pending sets; TypeScript just consumes memory.

This is live, not theoretical. Ghostlight sends Heimdall sealed envelopes
over CultMesh RUDP with max_fragment_bytes = 2048
(Ghostlight/crates/ghostlight-dungeon/src/heimdall.rs:697), and Heimdall's
private command plane reassembles them through this path. Every lost
fragment on that link left a buffer nothing would ever collect, on a
long-running auth service.

Caps pending sets at 64 to match cultnet-rs's max_pending_fragment_sets,
and evicts the oldest stranded set rather than refusing the payload:
a receiver that stops accepting fragmented traffic after a bounded number
of losses is a denial of service delivered by the network. Map iteration
is insertion-ordered, so the first key is the oldest. fragmentSetsEvicted
and pendingFragmentSetCount make it observable.

Found by the Muninn media load test, which hit the Rust half of the same
defect; cultcache-py has the same unbounded map and is not touched here.

  npm run test --workspace=packages/cultnet-ts -> pass 54, fail 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:42:13 +02:00
Meta Vens
05e0925143 Merge the media stream contract into CultLib 2026-09-10 02:32:21 +02:00
Meta Vens
96138ffcfa Validate on decode, where both ends inherit it
Moving the envelope moved less than it should have. Muninn's decoder validated
every record and checked the envelope's key against the key derived from the
record's own contents; the version that landed here did neither, so the move was
quietly a downgrade. Six of Muninn's tests failed on the switch and said so,
which is the only reason this is a paragraph rather than a bug.

The four record validators and the video chunk key move here with it. They check
the contract, not one producer's use of it: non-empty identifiers, non-zero
timebases, sane pressure figures, well-formed chunk keys. Left in Muninn they
would have protected the producer and left every consumer to invent its own idea
of a well-formed record — which is how a C++ receiver and a Rust sender came to
disagree while each looked correct alone.

The record-key check is the sharper one. A key is derived from the record it
addresses, so a mismatch means envelope and payload disagree about what the
thing is. Admitting that would let a consumer address media by a key its
producer never meant.

VideoChunkKey comes too. Its string form ("42:1") travels in receiver feedback
and is parsed at the other end, so it was already contract living in a producer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:23:56 +02:00
Meta Vens
a58968e7ac Bring the media delivery choice alongside the contract
Both branches are the same body of work seen from two angles: what a media
stream is, and what its channel does. Muninn needs both to build, so they merge
here rather than making a consumer track two refs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:07:13 +02:00
Meta Vens
a3e6877945 Move the media wire envelope where both ends can reach it
A media record travels wrapped in cultnet.document_put_raw.v0 with a schema id,
a record key and provenance. Both ends have to agree on that exactly, and until
now only Muninn had it: the producer was Rust in one repository and the consumer
was 6,515 lines of C++ in another, each with its own reading of the same bytes.
Ratatoskr could not decode a real stream without it.

Two things change in the move rather than after it.

The producer namespace is now the caller's. The Muninn implementation hardcoded
"muninn-media:" into every message id and "muninn.media" into every tag, so a
second producer would have had to label its traffic as Muninn's to be understood.
`MediaWireProvenance` carries it instead, and a test asserts the envelope names
no producer the caller did not supply.

The four hand-written wire payload structs are gone. Each restated a record's
entire field list as a positional tuple, in order, kept in step by discipline
alone — and existed for one reason: `#[serde(with = "serde_bytes")]` on the
payload, which cannot be applied to a DatabaseEntry struct because the derive
does not read serde attributes. Now that the records carry
`#[cultcache(key = N, bytes)]` the derive emits bin itself, so the records
serialize directly.

That last part is a wire claim, so it is tested rather than asserted:
`the_derive_reproduces_the_hand_written_wire_payload_byte_for_byte` reconstructs
the legacy struct and compares output byte for byte. It matches, which makes the
deletion a simplification instead of a silent break.

Record key shapes move here too. A consumer parses them to tell a chunk from its
siblings and a retransmission from a new frame, so they are contract and belong
beside the records rather than in whichever producer wrote them first.

Muninn's copies are not removed yet; that is the other half of this move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:00:19 +02:00
Meta Vens
4111fbb468 Write down that a stored record's encoding is part of its contract
Three byte-exact re-encode guards exist across three separate Idunn
stores -- the runtime projection, the deployment brake, and the trust
bindings -- each decoding a record, re-encoding it, and refusing on any
byte difference. Idunn's control store does the same for its own
transactions. Three independent instances is a house pattern, and it
means changing how any persisted CultCache record serializes is never a
flag flip.

Records the measured behaviour of #[cultcache(key = N, bytes)] rather
than the assumed one: reading stays compatible both ways, so the cost
lands on reproduction and therefore on those guards, not on
compatibility. A field with no live producer is free; a field with
persisted records needs its store rewritten in the same change.

Also records that Option<Vec<u8>> cannot take the attribute today, and
that a shape-validated field nobody produces may be a fossil rather than
an omission -- the estate has at least one other case of a live consumer
outliving its producer.

This was learned by nearly deploying an attribute flip that would have
made Idunn refuse the projection store Odin reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:18:45 +02:00
Meta Vens
36f31920ce Let a caller choose what the media channel does
`media` was hardcoded Reliable in two places — the profile builder and the
per-channel send options — so no caller could ask for anything else.

Measured on Raven through `cultnet-impair`, 848-byte packets at 12 Mbps, which
is Muninn's real media shape:

    profile    mode          delivered    ack bytes
    clean      unreliable      100.00%           43
    clean      reliable         69.10%      131,322
    loss 1%    unreliable       98.95%           43
    loss 1%    reliable         72.00%      135,837
    loss 3%    unreliable       96.73%           43
    loss 3%    reliable         73.30%      138,030

A reliable media channel loses 31% of a clean 12 Mbps offer, and its delivery
barely moves as loss rises, so loss is not the binding constraint — it cannot
carry the rate. An unreliable one holds everything on a clean link and then
degrades by about the loss injected. The reverse channel differs by three orders
of magnitude.

Muninn wanted the unreliable lane and had no way to say so. It signalled the
intent the only way the API allowed, by passing no reliable expiry, and its own
test has asserted Unreliable and failed since it was written. With this change
it passes.

Delivery now comes from the advertised profile, resolved by `channel_delivery`
alongside the `channel_reliable_expire_after_ms` that already worked this way.
That removes the send path's private opinion about reliability, so the profile a
peer was shown and the behaviour it gets cannot disagree. The channels whose
semantics are fixed by design — schema reliable and ordered, realtime unreliable
— are untouched and pinned by a test.

`media_delivery: None` means Reliable, so no existing consumer changes
behaviour. It is still source-breaking for anyone constructing the three options
types with a struct literal rather than `::client(...)`: two sites in
cultmesh-rs, four in Muninn, seven across the test suites here, all mechanical.
An earlier draft claimed Muninn was unaffected; the compiler disagreed.

The `examples/media_delivery_probe.rs` added here is what produced the table, so
the claim can be re-run rather than believed. Note its sender-side wire-cost
figure is unsound for the reliable case: the sender stops draining after five
seconds while retransmission is still in flight, so those byte counts
under-report. The delivery percentages are receiver-measured and stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:08:31 +02:00
Meta Vens
9e6027e8d8 Measure what adding a bytes slot does to records already written
Four of cultnet-rs's ten DatabaseEntry byte fields lack
#[cultcache(key = N, bytes)] and so serialize as MessagePack
array-of-integers rather than bin: IdunnDeploymentBrakeRecord.signature,
IdunnDaemonHealthTrustBindingRecord.signer_public_key,
GameCultServiceTrustAnchorRecord.signer_public_key, and
IdunnAuthenticatedProviderHealthProjectionRecord.signature. Converting
them touches live stores, so the question is what breaks.

Measured rather than reasoned, and the answer is the opposite of what I
expected. Reading is compatible in both directions: serde_bytes::ByteBuf
accepts an array marker, and a plain Vec<u8> accepts bin, so the flip is
neither a hard read break nor a one-way door. No dual-read path is
needed and reverting the binary is survivable.

What does change is reproduction. A record written before the flip can
be read but can never re-encode to the bytes it was stored as, and its
canonical digest moves with it. That is the cost, and it lands on
byte-exact store checks and canonical_sha256, not on readability -- so
the migration is a re-encode question, not a compatibility one.

Also pins the size difference the media work is chasing: 406 bytes as an
array against 278 as bin for a 256-byte payload, 1.46x, independently
matching the 1.45x measured on an 848-byte payload.

  cargo test --test byte_slot_migration -> test result: ok. 4 passed; 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:42:26 +02:00
Meta Vens
2ba404cc0b Carry media payloads as bin
The three media payload fields take the derive's `bytes` attribute, so they
serialize as MessagePack `bin` instead of an array of integers. An 848-byte
payload goes from 1234 bytes on the wire to 851 — 1.45x off every media byte,
on the one link measured to be starved.

This is a correction to the previous commit's reasoning, not a new capability.
That commit claimed the derive could not emit bin and left a test ignored
against a hint that needed designing. The hint has existed all along:
cultcache-rs-derive parses `bytes` at lib.rs:92, serializes through
serde_bytes::Bytes at :135, and decodes through ByteBuf at :154 and :170. What
is inert on a DatabaseEntry struct is `#[serde(with = "serde_bytes")]`, because
the derive reads only its own attribute namespace — which is a different and
much smaller claim than the one that was made.

The ignored test is now un-ignored and passing, and the tolerant assertion it
was propping up is exact again.

Four fields elsewhere are missing `bytes` and are deliberately untouched here:
IdunnDeploymentBrakeRecord.signature, IdunnDaemonHealthTrustBindingRecord
.signer_public_key, GameCultServiceTrustAnchorRecord.signer_public_key, and
IdunnAuthenticatedProviderHealthProjectionRecord.signature. Those carry stored
state and signing history — six trust anchors live in the deployment projection
on yggdrasil, and the brake store is byte-exact checked — so they need
sequencing with a re-sign rather than a flag flip. Owned by the Swarm Migration
work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:31:11 +02:00
Meta Vens
a250ec69eb Give the media stream records a home that is not one producer's
Four record types described a media stream and nothing about who produced it,
while living in odin-core under a `muninn.` prefix. Every field is
producer-agnostic; the name was the only coupling, and a second producer would
have had to impersonate Muninn or fork the shape. They move to cultnet-rs as
`gamecult.media_*`, matching the prefix already used for cross-service contracts
like gamecult.runtime_presence_health and gamecult.service_trust_anchor, while
cultmesh.* stays reserved for session primitives.

Field order and key indices are carried over untouched, including the `default`
on missing_video_chunk_keys. The tests pin what a consumer in another language
is entitled to rely on: positional layout, the slot each field occupies, and
that a record written before that field was appended still decodes.

One test is ignored rather than passing. Byte payloads currently serialize as
MessagePack arrays of integers because the derive writes its own Serialize and
ignores serde attributes; C#, TypeScript and Python all emit bin, which makes
Rust the divergent runtime and costs 1.45x on every media byte. Fixing it needs
an opt-in hint in cultcache-rs-derive rather than a global change, because
signatures are taken over the whole positional serialization and canonical
hashes over the same bytes — flipping byte encoding estate-wide would invalidate
the live Idunn authority chain. That work is sequenced by the Swarm Migration
session; the ignored test un-ignores when the hint lands.

Parked on its own branch. The Muninn and Odin ends of this move are not done:
odin-core still declares these types, and muninn-daemon still imports them from
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:17:19 +02:00
Meta Vens
d1a9edad3a Port gamecult.runtime_presence_health.v2 to cultnet-ts
The dual-proved runtime presence contract existed only in cultnet-rs, so no
TypeScript service could make a statement Odin admits as presence. Odin files
anything else away as an ordinary document and never produces a topology
correlation for it, which is why Heimdall could warm forever without being
promoted.

The port is what it should be: the same fields at the same key slots. The Rust
derive serialises a positional tuple indexed by those slots, so the TypeScript
side names the slots and fills the same 24. Both proofs cover one payload --
the complete record with both signature fields empty -- and the two signing
messages use the domain separation their respective identities already define.

Conformance is against vectors emitted by cultnet-rs, not a fixture written
for both sides at once, which would agree with itself and prove nothing:
`cargo run --example runtime_presence_vector -p cultnet-rs` regenerates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:42:54 +02:00
Meta Vens
b304df5e47 Promote the swarm document catalog into cultcache-ts
Odin carried defineOdinDocuments: 45 CultCache document-type definitions for
documents that cross service boundaries. Eve surfaces and provider
advertisements, Ghostlight, Heimdall, Idunn, Muninn, Sleipnir, Aetheria,
VoidBot, Stonks. Almost none of it is Odin. It lived there because Odin was
written first.

That was tolerable while Odin was the only consumer of consequence. It stops
being tolerable when Hermodr moves to its own repository, because the choice
then is a published contract or two definitions of one wire format, and the
second is the failure the Hermodr extraction exists to remove.

The catalog is now defineSwarmDocuments here, beside defineDocumentType, which
is what builds it. defineDocumentType stays injected rather than imported: that
is how it worked in Odin, it keeps the catalog clear of the generic signature in
./document, and it lets a caller supply another runtime.

Ported as a mechanical transform rather than retyped, then verified rather than
assumed: serialising all 45 definitions from both implementations against the
same probe gives byte-identical output and identical key sets. The 43 callbacks
that gained annotations are named DocumentValue rather than bare any, because
these definitions predate typing and read fields defensively, and pretending
otherwise would be inventing types nobody has verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:40:37 +02:00
Meta Vens
5407be713f Add generic signed daemon-health publication to cultnet-ts
Odin carried a 324-line module named idunn-rudp.cjs. Thirteen of those lines
mentioned Idunn, and most were error strings. The rest was CultNet client work:
socket binding, RUDP handshake, Ed25519 signing, packet receipt, msgpack
framing. It sat in a consumer because that is where it was written, not because
that is where it belonged, and it was about to be duplicated when Hermodr moves
to its own repository.

The transport now lives here as signed-daemon-health, beside the RUDP session it
builds on. The receiving service supplies a contract naming its connection id,
signed schema, and unsigned diagnostic schema; nothing service-specific is
hardcoded.

The signature and identity domains are deliberately not parameterised. They are
the shared gamecult.provider-health domains, and a caller able to vary them
could silently produce statements no verifier accepts. The signing purpose is
the caller's signed schema id, so a statement signed for one service cannot
verify as another's.

The module also loses the sibling-path assumption it was carrying: it no longer
resolves CultLib by walking up three directories from wherever it happens to
live, because it now lives in CultLib.

Type-checks clean and builds. Verified faithful by Odin's existing signed-health
test, which asserts exact canonical statement fields, the observation timestamp,
and Ed25519 signature verification against the ported implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:29:33 +02:00
Meta Vens
c13b6ba07e Move IdunnDaemonHealthTrustBindingRecord into cultnet-rs
odin-core only re-exported this contract from Odin's tree while the
sibling health/trust records already live in provider_health_contracts.rs.
Moving it here lets idunn-daemon depend on cultnet-rs directly instead of
odin-core, clearing the way to extract idunn-daemon into its own repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:39:46 +02:00
Meta Vens
ae0ac0d3c9 Reconcile cultmesh-rs: adopt Odin's lineage, keep CultLib's additions
CultLib's packages/cultmesh-rs and Odin's vendor/cultmesh-rs had diverged
independently -- 87 shared symbols, 6 only here, 9 only there. Neither was
simply ahead of the other, so this is a merge rather than a fast-forward.

Odin's copy is the base because it is the deployed code, running in production
via Idunn, and because it carries a deliberate semantic upgrade: connection_id
was replaced by a per-request message_id correlation with validation requiring
raw callers to supply one. That brings in the RUDP document server (647 lines)
and its tests (553), which the original migration into CultLib left behind.

Layered back in from CultLib's lineage: soa(), get_all_with_keys(),
create_rudp_document_message(), and the plural batch publisher. The batch
function was rewritten off the removed connection_id onto the connect helper.

Both publish paths survive deliberately. They are different jobs, not rival
implementations: the singular path polls one receipt for application-level
acknowledgement and can report Invalidated, while the batch path sends and
then flushes at the transport level. That is also why the census framed the
30s-versus-300ms flush_timeout as a value conflict and was wrong -- one field
name, two mechanisms. The distinction is now documented at the call site.

Surviving connection_id references are the RUDP protocol's own connection
epoch and are unrelated to the removed options field.

cargo test: 16 passed, 0 failed -- 10 lib, 6 document-server integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:26:44 +02:00
Meta Vens
77313ed48c Stop the interop harness leaking peers and hanging after it passes
The suite finished its work in about 60 seconds and then never exited. Four runs
were left alive for hours, holding 11 node and 2 dotnet peers, and a stranded
peer holding cultnet_interop_peer.exe is what made two unrelated cargo builds
fail with LNK1104. In CI this is a job that burns its whole timeout on green
tests.

Two real defects, found by asking the stuck process what it was waiting on
rather than by guessing. process.getActiveResourcesInfo reported four UDPWraps
and two PipeWraps still held after the last test.

The four UDP sockets: eight tests bind a server socket with bindUdpSocket and
close only the transport wrapper in their finally, never the socket itself. Four
leaked per run, matching the four Python lanes that fail on a machine without
msgpack -- the wrapper is built inside the try, so when the peer's readiness
rejects it never exists to be closed, and the raw socket is orphaned. Now closed
through a guarded helper that tolerates a transport having adopted it.

The teardown registration: t.after was registered after every `await ready`, so
a peer whose readiness rejected skipped the registration entirely and stranded
every peer already spawned. Moved ahead of the spawns at all four sites.

With those fixed the suite still does not drain on its own, so test:interop now
passes --test-force-exit. That is safe only because cleanup demonstrably
completes first: a forced run now leaves zero peers behind, where before it left
nine. Forcing the exit without the fixes above would have guaranteed orphans
rather than prevented them.

Verified: 65 seconds, exit 1, the expected 15 pass and 12 fail, no surviving
processes. The 12 failures are the Python lanes, which cannot run on this
machine and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:32:10 +02:00
Meta Vens
8cb3b7282b Publish the non-Rust packages and make the licensing uniform
Rust consumers pin a CultLib revision through cargo git dependencies, which is
how Ghostlight, Epiphany, CodexConnector and Odin already consume the core
stack. npm and pip cannot take a package from a subdirectory of a git repo at
all, which is why the TypeScript and Python consumers vendored forks instead:
there was nothing else to depend on. This gives them a versioned artifact.

Tagging cultcache-ts-v* or cultcache-py-v* builds, tests and publishes. The tag
version must match the manifest version or the workflow refuses rather than
shipping a mislabelled artifact. PyPI uses trusted publishing, so no long-lived
token is stored; npm needs an NPM_TOKEN secret.

cultcache-ts is now @gamecult/cultcache-ts. The scope is not cosmetic: it makes
the package unambiguously org-owned on a public registry, and it is what lets
CultLib hold the name rather than racing for an unscoped one. CultLib's own
cultmesh-ts and cultnet-ts and their imports move with it; all three packages
typecheck and cultcache-ts passes 12 of 13 tests, the exception being the
cross-runtime store test that shells out to Python, which cannot run on this
machine because its Python has no msgpack.

Licensing was inconsistent with the repo. cultcache-py declared itself "Private
GameCult infrastructure" and cultcache-rs and cultmesh-rs declared nothing,
inside a repo whose LICENSE is MIT and whose every other package says MIT. All
three now say MIT. That was load-bearing here: a package that calls itself
private is not one to push to a public registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:41:44 +02:00
Meta Vens
e171eca323 Expose what the consumer forks had to reach for
Surveying the swarm's vendored copies, the divergence is far smaller than the
number of forks suggests. Against CultLib, Epiphany's CultCache holds nothing
unique, CodexConnector's holds nothing, VoidBot's CultCache-TS holds nothing,
and Ghostlight's CultCache-Py holds nothing. What the forks did hold was mostly
reach: things CultLib has but does not expose.

raw_document_record_from_envelope was private here and public in every Rust
consumer fork. It is public now. A first pass called it missing, which was a
visibility difference read as an absence -- a `pub fn` scan cannot tell those
apart.

CultMesh gains get_all_with_keys and soa, thin delegations to CultCache, which
does support both. Those were genuinely absent rather than hidden.

cultmesh-rs also needed the media expiry field on its document transports; it
was not rebuilt when cultnet-rs grew that field because the earlier pass tested
only two of the three Rust packages.

cultcache-rs 53, cultnet-rs 107, cultmesh-rs 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:15:59 +02:00
Meta Vens
cbde940df2 Let the advertised profile own each channel's reliable expiry
The first pass put the media deadline on the transport options and carried it as
a runtime field on the connection and hub. That is a second answer to what a
channel does, able to disagree with the profile the peer was actually shown.

CultNetTransportChannel now carries reliable_expire_after_ms and the send path
resolves the deadline from the profile, so there is one answer. The runtime
fields are deleted rather than left as a shadow copy. The transport option
remains as the knob that populates the profile, not as a parallel authority.

Odin's fork had the field in exactly this place, which is how the gap surfaced:
its muninn test reads it off the channel descriptor.

This is wire-visible, unlike the rest of the expiry work. The field is optional
and skipped when absent, so a profile that does not set it is byte-identical to
one from a runtime that has never heard of it -- but the RUDP profile does set
it on the media channel, so Rust peers now advertise it. Verified against the
interop lane: TypeScript, C# and Kotlin all accept the profile unchanged, in
five passing lanes each. The Python lanes cannot run here because this machine's
Python runtime has no msgpack, and they fail identically before and after this
change.

The other four runtimes should grow the field so a profile round-trips through
them without losing the channel's declared behaviour.

cultnet-rs: 107 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:57:44 +02:00
Meta Vens
8f29ee568d Add the per-entry snapshot compare-exchange Odin consumes
A first pass at Odin's vendored CultCache compared only its uncommitted diff
against CultLib and concluded nothing else was missing. That was wrong: a
whole-file symbol comparison found CultCacheExpectedEnvelope and a per-entry
compare_exchange that CultLib lacks and Odin's crates import. Comparing a diff
tells you what changed recently, not what the fork uniquely holds.

Unlike the snapshot form this constrains only the identities it names, so an
unrelated concurrent write does not lose the race. Expecting `None` makes it a
create-once claim, which is what an admitted-generation owner needs.

The expectation-identity guard is carried over as written even though it is
unreachable: a condition whose envelope names a different identity is already
rejected by the value comparison above it. Left in place rather than quietly
dropped, since it costs nothing and the lookup it depends on may change.

cultcache-rs: 53 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:32:07 +02:00
Meta Vens
bac5be88b9 Restore reliable expiry and snapshot compare-exchange to the Rust core
Two capabilities existed only in Odin's vendored forks, which is why the forks
could not be deleted. Both are ported here with tests so Odin can consume
CultLib instead.

cultnet-rs gains reliable send expiry. A reliable packet may now carry a
deadline; past it the packet is dropped instead of retransmitted, and the count
is reported through session and transport stats. Live media tolerates loss but
not lateness -- a frame retransmitted after its moment has passed is worse than
a missing one -- so the media channel takes a 75ms default while state channels
never expire. Handshake packets never expire either: a dropped connect is not a
late frame.

The deadline is fixed when a send is admitted, so time spent waiting in the
backlog spends it exactly as time spent awaiting an acknowledgement does. This
lineage has a queued_reliable backlog that Odin's does not, and a queued packet
promoted after its moment had passed would be as useless as a retransmitted
one. Expiry runs before the window is measured, so an expiring stream is not
refused for a backlog it no longer owns.

cultcache-rs gains compare_exchange_snapshot and a nonblocking
try_compare_exchange_snapshot. CultLib had neither, despite carrying the larger
body -- the two lineages had diverged in both directions. Contention is returned
as data rather than waited on, so the caller keeps retry and scheduling
authority. This is the primitive an admitted-generation CAS needs.

No wire format changes. Expiry is a sender-side policy and compare-exchange is
local storage, so the other four runtimes stay in agreement without matching
these refusals. They are still worth carrying eventually, and the parity ledger
already tracks that this kind of gap does not announce itself.

cultnet-rs: 106 tests. cultcache-rs: 51 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:21:01 +02:00
Meta Vens
85f7024a35 Refuse a framed length beyond the advertised maximum
Found while checking what would be lost by deleting Ghostlight's vendored fork.
Its copy of transport.rs carried a guard CultLib never had: tcp_framed receive
compared the declared payload length against the max_payload_bytes the profile
advertises for the schema channel, and refused before allocating.

Without it the length in a four-byte header is taken on trust and used directly
to size a buffer, so one header reserves whatever the peer names before a single
payload byte arrives. The guard sits ahead of the allocation, which is the only
place it does any good.

It is inert unless a profile advertises a maximum, so the test advertises one.

This is the last thing unique to that fork. Everything else in it is now here or
was already here, which is what makes the fork safe to delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:09:11 +02:00
Meta Vens
5dbc0c29f5 Bound the RUDP receive path and refuse hostile sequences
Clears the cultnet half of the retained debt. The two unported commits from the
retiring GameCult/cultnet-rs lineage were not portable as patches: this lineage
had reworked the same receive path with sequence domains and ACK horizons, and
applying them raised nine conflicts that would have reverted that work. Most of
what they added already existed here by another route -- received-sequence
history, fragment-set count and reassembly bytes are all bounded already. So
the gaps were taken rather than the patches.

Three were real. The per-channel maps grew without limit, and a peer picks its
own channel ids, so ordered and sequenced channel tracking is now capped at 64
each. The out-of-order hold buffer grew without limit, and a peer picks its own
send order, so it is capped at 1024 frames and 4 MB; sizes are computed from the
buffer rather than tracked in counters, which cannot drift.

The fourth was the sharpest. remember_received ran before the duplicate
early-return, so a single forged packet far past the current highest advanced
the receive state beyond every sequence still in flight and starved the
session. Such a packet is now refused without being remembered.

Reliable sequence exhaustion aborted the process through expect(). The capacity
gate that already guards the send window now also refuses when the sequence
space cannot cover the request, and allocation saturates rather than panicking
so no future path can reintroduce the abort.

Five tests cover the bounds and both refusals. cultnet-rs passes 103,
cultcache-rs 48, cultmesh-rs 7, and the TypeScript-Rust RUDP interop lanes pass
5 of 5 with the hardened peer, so legitimate traffic is unchanged.

The parity ledger now records what this actually leaves owed: these are
refusals, not wire-format changes, so the interop lanes stay green whether or
not the other four runtimes match. The gap will not announce itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:57:07 +02:00
Meta Vens
eb74ead837 Restore cross-runtime schema recovery and prove staging cleanup
Taking the standalone body as the CultCache base dropped a0b31b8, and it was a
real regression rather than a missing convenience. A record whose catalog entry
is absent -- written by another runtime, or older than the catalog it was
stamped against -- made decode_store_snapshot fail, and one such record failed
the entire pull. Recovering the type from the payload is what "across runtimes"
meant in that commit's title.

Restored in full: the payload is a MessagePack array whose first field is the
schema version, so the schema name is recoverable from the record itself; the
registry now also indexes schema name to entry type and refuses a name claimed
by a second type; and pull resolves a persisted type that is either a
registered type or a recovered schema name. The stale schema id is preserved on
the envelope rather than rewritten.

743eaa5's durability half needed no code. The standalone lineage reached the
same place independently through crash-atomic single-file snapshots and
abandoned staging cleanup, and its cleanup carries the same protections: only a
candidate named for this exact store plus a canonical UUID is removed, and
anything that is not a regular file is skipped. Untested, though, which is how
a careful deletion rule quietly becomes a careless one. Now asserted: an
abandoned canonical candidate is removed while a non-UUID lookalike, a bare
.tmp suffix, and another store's candidate all survive, and the committed
snapshot still reloads.

48 tests pass. This clears the cultcache half of the retained debt from b672fb7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:45:48 +02:00
Meta Vens
fd676ce74d Finish the sequence-domain split in Python and Kotlin
Separating the reliable and lossy sequence domains landed in Rust, TypeScript
and C# on a branch. The Python and Kotlin halves were written at the same time
and left uncommitted in the worktree, so three runtimes spoke the new contract
and two spoke the old one -- the exact cross-runtime disagreement this
repository exists to prevent.

Both were already complete and implement the same three rules as the others:
ordered delivery requires reliability, sequenced channels carry their own
per-channel counters with latest-wins delivery, and duplicate suppression is
gated on packet.reliable so lossy datagrams are remembered nowhere.

Python also gains the three tests Rust, TypeScript and C# each received and it
did not: lossy packets cannot create reliable ordered gaps, unreliable
sequenced delivery is scoped to its channel, and unreliable ordered delivery is
rejected. Python passes 141.

Kotlin is committed unverified. This machine has no JVM or Gradle, and the
package carries no runtime-local test file, so its only gate is the interop
lane in CI. The change mirrors the four runtimes proven here line for line, but
mirroring is not evidence and CI is the witness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:40:05 +02:00
Meta Vens
85bc4c71b5 Align the replay-window test with the reliable sequence domain
Separating the sequence domains gated duplicate suppression on packet.reliable
in Rust, TypeScript and C# alike, so lossy datagrams are remembered nowhere and
a replayed one is delivered again. That is the intended contract -- lossy
traffic is fire-and-forget and its history is bounded by not existing -- but
replay_history_is_bounded_and_idle_hub_sessions_expire still asserted the old
behaviour with a lossy packet and had been left failing on the branch.

The three tests the change added in each runtime cover ordered-requires-
reliable, per-channel sequenced scope, and lossy gaps. None covers the bounded
replay window, which was this test's real subject, so removing the assertion
would have dropped the only coverage of a security property.

The lossy assertion now states the new contract, and the reliable domain gets
its own replay check: 4,100 acknowledged reliable sends, then the oldest
replayed and rejected by the window.

Rust 151, C# 148, TypeScript 48, Python 138 all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:36:36 +02:00
Meta Vens
8d3025816d Align RUDP reliability tests with sequence domains 2026-09-04 00:33:23 +02:00
Meta Vens
0736ae4824 Separate RUDP reliable and lossy sequence domains 2026-09-04 00:33:16 +02:00
Meta Vens
ca9895c06a Wire AGENTS.md into Claude Code memory
Claude Code reads CLAUDE.md, not AGENTS.md, so this repo's doctrine was
invisible to it. This one-line import makes the existing AGENTS.md load
without duplicating or renaming it. Codex is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:33:01 +02:00
Meta Vens
4c0dc07ba5 Make the Rust suite green and stop silently ignoring packages
exact_reliable_acks_clear_more_than_one_ack_window_of_fragments has been
failing on main. It was not a defect: send_many returned 32 packets where the
test expected 40, and 32 is exactly CULTNET_RUDP_RELIABLE_SEND_WINDOW_PACKETS.
The reliable send window landed and this test kept asserting the pre-window
behaviour, so a test whose name is about crossing an ack window had stopped
crossing one. It now asserts that a window's worth leaves immediately, that
the remaining eight fragments queue, and that acknowledgements promote them
until every fragment is delivered -- the same loop packages/cultnet-rs/tests
/cultnet.rs already uses for this contract.

All three Rust crates are green: 46, 95 and 7.

The NuGet packages rule hides every language package until one is named in
the re-include list, and cultmath, cultmesh-browser and cultmesh-kotlin were
never added. Nothing is currently lost -- every source file in them is
tracked -- but any new file added to those three would have been dropped in
silence. cultmesh-rs hit this on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:01:17 +02:00
Meta Vens
62ac7e3483 Record the unported RUDP hardening as owed work
Exact acknowledgement receipts landed; bounded session memory and hostile
sequence rollover rejection did not. Until they do, this runtime accepts
sequence and reassembly input the retiring lineage rejects, so its repository
cannot be deleted yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:14:45 +02:00
Meta Vens
9f822b7578 Expose exact RUDP acknowledgement receipts
Ported from GameCult/cultnet-rs 9dcb88d into CultLib, where the wire contract
belongs. Ghostlight's committed idunn_health.rs imports
CultNetRudpReliableSendStatus to hold an Idunn process write lease current,
and the symbol existed only in the standalone lineage, so nothing could
consume this contract from its canonical home.

Three reconciliations were needed because both lineages reworked RUDP
independently since the 014f998 copy-in:

- reset_peer_state: kept this lineage's version, which also resets the
  sequence and fragment counters and clears queued_reliable, and folded in
  only the imported change that matters -- rotating session_scope so a peer
  reset invalidates outstanding receipts. The imported body also cleared
  highest_delivered_sequenced_by_channel, a field the sequence-domain work
  renamed away here.
- channel_send_options is a free function in this lineage, not a method.
- The imported guard rejected reliable channels with an expiry. This lineage
  has no reliable-expiry concept at all -- the only mentions of expiry in the
  file arrived with this patch -- so every reliable channel is already
  non-expiring and the guard reduces to the reliability check. A comment
  marks where to restore the expiry half if expiry is ever added.

94 tests pass. The one rudp_server_hub failure is pre-existing and reproduces
on clean origin/main.

The other two standalone RUDP commits are deliberately not ported. Bounding
session memory and rejecting hostile sequence rollover rebuild the receive
path -- stale-sequence rejection, per-channel tracking caps, reassembly and
ordered-buffer byte accounting -- in the same code this lineage reworked with
sequence domains and ACK horizons. Merging them changes wire-observable drop
and reassembly behavior, which needs the cross-runtime interop harness to
validate, not a local build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:14:30 +02:00
Meta Vens
67cbb4ea36 Give the Rust CultMesh runtime a home
The only Rust CultMesh implementation in existence was 1,363 lines inside
Ghostlight's vendor directory. It was in no library repo and no standalone
repo, so it had never been checked against the browser, Kotlin, and
TypeScript CultMesh bodies it must interoperate with.

It is not a duplicate of the facade in cultnet-rs. That one is the small
surface the parity table claims for Rust: peer cards, authority leases,
endpoint parsing. This is the stream and session layer -- node, document
sets, RUDP document publication, stream descriptors, negotiation, stream
catalog, and shared-memory frame rings -- which the same table lists under
Rust's "Not claimed". A consumer grew the unclaimed surface because there was
nowhere else to put it.

Its path dependencies already matched this layout and it builds against
CultLib's cultnet-rs unchanged, so it does not depend on the forked RUDP
extras. Seven runtime-local tests pass, and CI now runs them.

The parity table gains no claim from this. The Rust row says plainly that no
interop lane exercises the stream surface, and expansion work records that
runtime-local tests are not a parity claim. Landing code is not evidence.

packages/cultmesh-rs needed an explicit .gitignore re-include; the NuGet
packages rule hides every language package until one is added by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:09:45 +02:00
Meta Vens
a539d4fb1c Return the Idunn and Odin authority contracts to CultLib
The standalone GameCult/cultnet-rs holds six modules CultLib never received:
runtime_authority_contracts, service_identity, provider_health_contracts,
idunn_deployment_brake, idunn_lifecycle_brake, and snapshot_query. They own
Idunn Expected incarnations, dual-proved runtime activation, process write
leases, service-signed presence health, and Odin topology correlation -- the
wire contracts every GameCult daemon must agree on.

They belong here because wire parity conformance lives here and nothing
outside it is checked for cross-runtime agreement. Held as source to be
copied, this contract already forked: Ghostlight vendors it at e542225 while
its own authority map names f4ab237, and the two disagree on whether Present
may coexist with a disagreement. One copy makes them exclusive; the other
separates presence from readiness and gates through into_undisputed_present.
A single owner under the parity harness is the fix; re-vendoring is not.

None of the six touch RUDP, so they land without disturbing the protocol
core. rudp.rs remains genuinely forked -- 140 hunks between the lineages,
with reliable windows and ACK horizons on this side and bounded session
memory, exact acknowledgement receipts, and hostile rollover rejection on the
other. That reconciliation needs the cross-runtime interop harness and is not
attempted here.

Adds ed25519-dalek, zeroize, and the Windows Foundation/Cryptography features
the credential paths require. 96 tests pass. The two rudp_server_hub failures
are pre-existing on this base and reproduce at 230ace6 untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:56:59 +02:00
Meta Vens
b672fb7a37 Return the Rust CultCache engine to CultLib
packages/cultcache-rs forked from GameCult/cultcache-rs at the 5c69bfc
monorepo import and the two lineages then diverged in both directions. The
standalone repo took 28 commits of engine work CultLib never saw: typed SoA
projection, atomic heterogeneous batch publication, single-entry and
conditional batch CAS, a transactional keyed redb backend with serialized
cross-process opens, lockless provider snapshot reads, RAII read-only shared
snapshots, snapshot-fenced replace/append, crash-atomic single-file snapshots,
compaction fences, portable and canonical byte fields, and unified retained
storage authority. That body is 3,714 lines with 48 tests against CultLib's
1,554 with 18.

CultLib is the canonical home because wire parity conformance lives here and
nothing outside it is tested for cross-runtime agreement. A runtime-specific
core-stack repo cannot hold the engine every other runtime must agree with.

The standalone body lands as the base rather than merging 28 commits backward.
It is already proven against a CultLib-derived cultnet-rs: Ghostlight vendors
exactly this pairing and compiles. Here, cultcache-rs passes 46 tests and the
cultnet-rs RUDP gate passes 20 with no source change.

Retained debt, not carried forward: a0b31b8 recovered schema-stamped records
missing catalog entries (SchemaStamped, infer_schema_name,
infer_schema_version_from_payload) and 743eaa5 hardened snapshot replacement
(SnapshotReplacementStage, CommittedSnapshotDurabilityUncertain, stale
candidate cleanup, with_exclusive_lock_using_unlock). Their tests were
white-box against internals this body does not have. The standalone
independently made single-file snapshots crash atomic and cleaned abandoned
staging files, so the durability guarantee is probably held under other names
-- probably is not proven. Re-establish both as black-box tests against the
new API before the standalone repo is retired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:56:59 +02:00
Meta Vens
334e60f192 Recover authority-bound CultMesh and atomic generations 2026-08-26 19:01:24 +02:00
Meta Vens
63d6094c94 Clarify CultMath and CultGeometry ownership 2026-08-26 17:47:06 +02:00
Meta Vens
5b2c36329d Add CultGeometry isosurface extraction 2026-08-26 17:37:22 +02:00
Meta Vens
ea88cfcd05 Move CultMath into the CultLib monorepo 2026-08-26 17:35:51 +02:00
Meta Vens
75c180782a Acknowledge retransmits across RUDP horizons 2026-08-23 19:15:22 +02:00
Meta Vens
ed273e6865 Keep large RUDP test receiver alive 2026-08-23 19:05:54 +02:00
Meta Vens
fda51fad50 Acknowledge reliable RUDP windows cumulatively 2026-08-23 19:00:33 +02:00
Meta Vens
a2e3d3fa35 Bind reliable windows to the RUDP ACK horizon 2026-08-23 18:54:18 +02:00
Meta Vens
b7949d31f2 Tolerate UDP reset advisories during RUDP polling 2026-08-23 18:46:35 +02:00
Meta Vens
289b792284 Exercise RUDP reliability in fleet CI 2026-08-23 18:43:17 +02:00
Meta Vens
21ce4f0ec6 Repair CultNet interop workflow setup 2026-08-23 18:42:07 +02:00
Meta Vens
f4dabfefb0 Bound RUDP reliable transmission windows 2026-08-23 18:35:49 +02:00
Meta Vens
5cefa0db00 Resolve CultMesh URI RUDP bootstraps in TypeScript 2026-08-22 03:25:02 +02:00
Meta Vens
4f6e95bb3d Carry editable field authority in CultMesh bindings 2026-08-22 00:05:57 +02:00
Meta Vens
3cec5463b2 Fence body commits by demand generation 2026-08-19 14:27:22 +02:00
Meta Vens
21fd4e07eb Version hot-body demand generations 2026-08-19 14:10:13 +02:00
Meta Vens
73ab8b65e1 Reconcile live subscription authority 2026-08-19 13:55:03 +02:00
Meta Vens
eaf1189cc1 Project subscription records per peer 2026-08-19 13:24:51 +02:00
Meta Vens
0a885afce7 Authorize database subscription records 2026-08-19 12:12:52 +02:00
Meta Vens
b9cbd7561d Gate cache hydration and preserve legacy generations 2026-08-19 02:36:49 +02:00
Meta Vens
72153a46a2 Seal cache transactions and expose coherent generations 2026-08-19 02:17:38 +02:00
Meta Vens
e80383c33f Make CultCache transactions atomic to observers 2026-08-19 01:44:17 +02:00
Meta Vens
81ca92fe40 Keep CultCache readers on one committed generation 2026-08-19 00:56:37 +02:00
Meta Vens
eea8248877 Make CultCache page commits content-addressed 2026-08-19 00:11:20 +02:00
Meta Vens
8a203c165f Publish atomic CultCache Unity package 2026-08-18 23:44:51 +02:00
Meta Vens
cc89aae4a0 Make CultCache directory commits generation-atomic 2026-08-18 23:42:15 +02:00
Meta Vens
7a6f0be21f Bind CultMesh application ingress to session identity 2026-08-18 23:08:59 +02:00
Meta Vens
99458dbb57 Ship authenticated remote transports in Unity package 2026-08-18 20:23:27 +02:00
Meta Vens
c194bd4cc8 Harden CultMesh route fallback and secure onboarding 2026-08-18 19:41:03 +02:00
Meta Vens
f622ee1b24 Authenticate CultMesh authority routes and replay operations 2026-08-18 19:02:46 +02:00
Meta Vens
6835ea4c10 Make Eve onboarding prove packaged authority routes 2026-08-18 17:39:59 +02:00
Meta Vens
6c848bd6c0 Bind CultMesh authority identity to physical routes 2026-08-18 17:21:58 +02:00
Meta Vens
21163d83fa Make CultMesh identity and route recovery explicit 2026-08-18 10:42:33 +02:00
Meta Vens
b542f4b644 Publish the actual CultLib Unity 1.0.46 runtime 2026-08-18 10:24:20 +02:00
Meta Vens
c75051017d Make CultMesh session identity explicit 2026-08-18 07:26:55 +02:00
Meta Vens
d4d24385d1 Prove clean .NET Eve package onboarding 2026-08-18 06:33:40 +02:00
Meta Vens
22bb9afaa4 Use one CultMesh node in Eve onboarding 2026-08-18 06:23:26 +02:00
Meta Vens
e74ba6e94c Make Eve onboarding one coherent game 2026-08-18 06:05:15 +02:00
Meta Vens
b0a876a220 Add typed CultNet operation dispatch 2026-08-18 05:59:11 +02:00
Meta Vens
35d31ae908 Support mapped frame bodies on Unix 2026-08-18 04:18:46 +02:00
Meta Vens
f65352fa73 Measure mapped hot-body copy cost 2026-08-18 04:03:06 +02:00
Meta Vens
bfaf7a8456 Gate retained operation session growth 2026-08-18 03:56:24 +02:00
Meta Vens
e49f93f56d Invoke typed operations through retained clients 2026-08-18 03:49:33 +02:00
Meta Vens
0943128365 Serialize the onboarding sample build 2026-08-18 03:41:47 +02:00
Meta Vens
cb7f9e8fa2 Retain native clients across provider routes 2026-08-18 03:34:59 +02:00
Meta Vens
3c19e54d91 Make Eve onboarding one executable path 2026-08-18 03:17:52 +02:00
Meta Vens
44abedfc9f Measure reactive performance across runtimes 2026-08-18 03:08:01 +02:00
Meta Vens
af4efb8784 Build TypeScript dependencies before reactive verification 2026-08-18 02:54:10 +02:00
Meta Vens
5e86427a64 Run portability gates on branch pushes 2026-08-18 02:49:52 +02:00
Meta Vens
d2a7693611 Make Eve artifact onboarding one command 2026-08-18 02:47:34 +02:00
Meta Vens
913ed627c6 Gate CultMesh portability on Windows and Linux 2026-08-18 02:42:57 +02:00
Meta Vens
fbf93f3570 Measure and bound reactive document cost 2026-08-18 02:37:13 +02:00
Meta Vens
f38966b4b9 Bound retained session callback resources 2026-08-18 02:29:14 +02:00
Meta Vens
669f638111 Share one scheduler across Python reactive documents 2026-08-18 02:25:22 +02:00
Meta Vens
989253a208 Reuse Odin fixture for product route witnesses 2026-08-18 02:18:56 +02:00
Meta Vens
c5d9435deb Carry portable schema identity on raw records 2026-08-18 02:08:49 +02:00
Meta Vens
a2301b6b09 Share CultNet handlers across transports 2026-08-18 01:31:04 +02:00
Meta Vens
be846415d4 Index CultCache records by document type 2026-08-18 01:07:25 +02:00
Meta Vens
ba680020b9 Prove reactive scheduling scales with edits 2026-08-18 00:44:41 +02:00
Meta Vens
d563a1ac64 Discover browser providers through Odin 2026-08-18 00:34:44 +02:00
Meta Vens
8eb89e38ec Add real browser CultMesh transport proof 2026-08-18 00:05:57 +02:00
Meta Vens
4af7398372 Normalize CultNet schema files 2026-08-17 23:27:07 +02:00
Meta Vens
3fe2e9be1b Publish browser subscription wire contracts 2026-08-17 23:26:51 +02:00
Meta Vens
54e4eacd13 Validate Eve getting-started contracts 2026-08-17 23:18:15 +02:00
Meta Vens
1fc68a4701 Route CultMesh sessions by stable Verse identity 2026-08-17 22:42:08 +02:00
Meta Vens
03840d0430 Publish CultLib Unity 1.0.45 artifacts 2026-08-17 21:30:32 +02:00
Meta Vens
ec292a6a00 Release CultLib Unity 1.0.45 2026-08-17 21:26:44 +02:00
Meta Vens
376c08a155 Make CultMesh client resources explicit 2026-08-17 20:39:24 +02:00
Meta Vens
32258f49c3 Add artifact-only Eve two-runtime sample 2026-08-17 20:21:41 +02:00
Meta Vens
1e1cfd840c Remove CultMesh Python mutation polling 2026-08-17 20:03:42 +02:00
Meta Vens
425b430fe6 Make CultMesh TypeScript document writes explicit 2026-08-17 19:58:54 +02:00
Meta Vens
98bec47d75 Make CultMesh document writes authority-explicit 2026-08-17 19:47:10 +02:00
Meta Vens
fcdddda72f Rebuild Unity package with rectangle API 2026-08-07 20:37:37 +01:00
Meta Vens
c4543b9443 Add canonical min-max rectangle 2026-08-07 20:02:24 +01:00
Meta Vens
b43cf4904f Fix CultMath Unity plugin references 2026-07-22 05:07:32 +01:00
Meta Vens
9a5597d233 Add precompiled CultMath Unity package 2026-07-22 04:52:09 +01:00
Meta Vens
69856d4f3a Set CultMath release version 0.1.0 2026-07-22 04:29:59 +01:00
Meta Vens
6a162606e4 Transfer planetary geometry to GameCult.Geometry 2026-07-22 03:47:38 +01:00
Meta Vens
f67f5122ed Release CultLib Unity 1.0.43 2026-07-20 03:44:37 +01:00
Meta Vens
5e742c26ae Complete native QUIC certificate validation 2026-07-20 03:43:05 +01:00
Meta Vens
02f99cc1c8 Release CultLib Unity 1.0.42 2026-07-20 03:08:18 +01:00
Meta Vens
c1c1792dc5 Serve provider-owned content chunks directly 2026-07-20 03:05:28 +01:00
Meta Vens
b18fed412e Release CultLib Unity 1.0.41 2026-07-20 02:45:39 +01:00
Meta Vens
278084fd24 Generate codecs only for compatible document shapes 2026-07-20 02:38:18 +01:00
Meta Vens
2194d14f0b Attribute CultCache page hydration by schema 2026-07-20 02:38:17 +01:00
Meta Vens
13dc1368dd Trace CultMesh local startup phases 2026-07-20 02:31:10 +01:00
Meta Vens
d69fa61412 Hydrate durable globals before defaults 2026-07-20 02:23:42 +01:00
Meta Vens
f714ceee43 Skip clean CultCache directory flushes 2026-07-20 02:12:22 +01:00
Meta Vens
cce542d824 Release CultLib Unity 1.0.40 2026-07-20 01:51:02 +01:00
Meta Vens
085e177742 Make CultCache hydration subscription-shaped 2026-07-20 01:48:56 +01:00
Meta Vens
54e2e69ff7 Release CultLib Unity 1.0.39 2026-07-20 00:58:26 +01:00
Meta Vens
916054fa0e Parallelize paged CultCache hydration 2026-07-20 00:46:38 +01:00
Meta Vens
c6cf04a342 Decouple realtime publication from peer sockets 2026-07-19 23:21:00 +01:00
Meta Vens
8cac8c9ec7 Isolate realtime provider broadcasts from departed peers 2026-07-19 23:13:23 +01:00
Meta Vens
1d126f4a35 Add native QUIC realtime transport for Unity 2026-07-19 22:55:06 +01:00
Meta Vens
1742473845 Authenticate advertised QUIC provider routes 2026-07-19 22:15:22 +01:00
Meta Vens
36a822d914 Release CultLib Unity package 1.0.35 2026-07-19 20:29:56 +01:00
Meta Vens
ff89a5f007 Stream CDN artifacts in coarse durable windows 2026-07-19 20:29:07 +01:00
Meta Vens
c148d891c1 Release CultLib Unity package 1.0.34 2026-07-19 19:06:31 +01:00
Meta Vens
638b33b830 Expose TCP schema host health 2026-07-19 18:53:08 +01:00
Meta Vens
6beca4a80a Preserve peer locality across schema transports 2026-07-19 18:50:22 +01:00
Meta Vens
899aea980f Benchmark realtime QUIC against legacy RUDP 2026-07-19 18:45:43 +01:00
Meta Vens
5e1633698e Add MsQuic realtime transport package 2026-07-19 18:36:50 +01:00
Meta Vens
c16163a82f Modularize CultMesh transport planes 2026-07-19 17:57:26 +01:00
Meta Vens
434db81e43 Postmortem CultMesh bulk RUDP failure 2026-07-19 17:13:51 +01:00
Meta Vens
ee0fb23503 Benchmark dedicated CultMesh body planes 2026-07-19 16:51:11 +01:00
Meta Vens
2afec5c5dc Remove scheduler sleeps from RUDP body bursts 2026-07-19 16:28:06 +01:00
Meta Vens
d018bb1fb5 Rebuild Unity transport assemblies 2026-07-19 14:11:51 +01:00
Meta Vens
091200c09d Move hot bodies off snapshot state 2026-07-19 13:57:06 +01:00
Meta Vens
0ef59b50c2 Materialize exact reactive state on demand 2026-07-19 12:33:03 +01:00
Meta Vens
a1f25af2e0 Keep exact live subscriptions active before publication 2026-07-19 11:49:18 +01:00
Meta Vens
5587f5c6d1 Add ergonomic live body subscriptions 2026-07-19 10:55:27 +01:00
Meta Vens
f3d008023e Release CultLib 1.0.27 2026-07-19 09:26:34 +01:00
Meta Vens
736b1f9278 Add exact reactive value subscriptions 2026-07-19 09:23:39 +01:00
Meta Vens
4d2ddb5beb Default hot body subscriptions to live delivery 2026-07-19 09:20:40 +01:00
Meta Vens
fd070cc8b2 Serialize concurrent RUDP session publication 2026-07-19 08:36:09 +01:00
Meta Vens
42402ab64b Deliver reactive subscriptions without replica writes 2026-07-19 08:07:48 +01:00
Meta Vens
4f816a4926 Withdraw reactive state when peers disconnect 2026-07-19 06:40:05 +01:00
Meta Vens
743fa3889e Stream exact hot state over negotiated body planes 2026-07-19 05:54:24 +01:00
Meta Vens
242c8aaddd Stream mapped frame generations outside CultNet 2026-07-19 04:35:12 +01:00
Meta Vens
f4b8e07da5 Package CultLib 1.0.21 Unity assemblies 2026-07-19 03:33:20 +01:00
Meta Vens
3e223ceecd Negotiate exact hot-body transport demand 2026-07-19 03:17:11 +01:00
Meta Vens
693df0901d Release CultLib 1.0.19 2026-07-19 00:54:26 +01:00
Meta Vens
5c075bc212 Fence provider RUDP connection generations 2026-07-19 00:50:20 +01:00
Meta Vens
ba2ef582d2 Add Rust provider session transport 2026-07-19 00:49:53 +01:00
Meta Vens
743eaa5507 Harden Rust CultCache snapshot replacement 2026-07-19 00:49:52 +01:00
Meta Vens
49c43baa4c Preserve dirty CultCache state during pulls 2026-07-18 23:19:45 +01:00
Meta Vens
3093b536d4 Release CultLib Unity package 1.0.17 2026-07-18 22:34:22 +01:00
Meta Vens
a32271d7c2 Make CultCache multiprocess pulls incremental 2026-07-18 22:25:13 +01:00
Meta Vens
69caf2d027 Release CultLib Unity package 1.0.16 2026-07-17 13:18:22 +01:00
Meta Vens
bb7e06ba1e Synchronize RUDP resend acknowledgements 2026-07-17 13:17:09 +01:00
Meta Vens
cb481b4c75 Add Unity-compatible simplex noise semantics 2026-07-17 07:45:28 +01:00
Meta Vens
419053ebe2 Release CultLib Unity package 1.0.15 2026-07-16 14:09:24 +01:00
Meta Vens
682a3e9f8d Bind mapped CDN bodies to verified paths 2026-07-16 14:08:13 +01:00
Meta Vens
15bffc6d8c Record released cold content proof 2026-07-16 03:35:20 +01:00
Meta Vens
4b7162022a Release CultLib Unity package 1.0.14 2026-07-16 02:37:52 +01:00
Meta Vens
25231429c1 Drain RUDP content traffic without per-packet sleeps 2026-07-16 02:36:48 +01:00
Meta Vens
8965f3c0e0 Authenticate provider session ingress 2026-07-15 01:54:06 +01:00
Meta Vens
d4934233ed Return RUDP application receipts 2026-07-15 01:44:53 +01:00
Meta Vens
ebd8c78c8b Project live publications as typed document handles 2026-07-15 01:37:24 +01:00
Meta Vens
262b5cc53d Add provider session wire transport 2026-07-14 23:47:30 +01:00
Meta Vens
18cebf740f Add durable TypeScript provider sessions 2026-07-14 23:13:13 +01:00
Meta Vens
b943fa7ef0 Record released cold content proof 2026-07-14 04:00:03 +01:00
Meta Vens
feb5c71513 Release CultLib Unity package 1.0.13 2026-07-14 03:27:26 +01:00
Meta Vens
a311cbe8cb Improve managed content throughput 2026-07-14 03:26:43 +01:00
Meta Vens
26b4d1eadc Release CultLib 1.0.12 with current Unity assemblies 2026-07-14 01:35:18 +01:00
Meta Vens
dc3ff2d9bb Release CultLib 1.0.11 2026-07-14 01:25:41 +01:00
Meta Vens
9305d53ed5 Expose managed content providers on CultMesh clients 2026-07-14 01:19:26 +01:00
Meta Vens
7d57140275 Add managed CultMesh content sessions 2026-07-14 01:13:23 +01:00
Meta Vens
6528f8ea2f Make TypeScript packages release-packable 2026-07-13 23:47:38 +01:00
Meta Vens
085aa2c075 Release CultLib 1.0.10 2026-07-13 21:10:55 +01:00
Meta Vens
267ff356ea Honor schema aliases in store publications 2026-07-13 21:10:54 +01:00
Meta Vens
b1fcd95c25 Release CultLib Unity 1.0.9 2026-07-13 19:39:19 +01:00
Meta Vens
1f40bbe411 Address CultMesh body publications by generation 2026-07-13 19:35:52 +01:00
Meta Vens
b73e04e011 Release CultLib Unity package 1.0.8 2026-07-13 18:50:30 +01:00
Meta Vens
7d21bb0e1d Publish CultMesh network body bytes 2026-07-13 18:14:41 +01:00
Meta Vens
5e867bd92c Add typed CultMesh body publications 2026-07-13 17:48:42 +01:00
Meta Vens
dbe6a3f9de Publish cross-process CultMesh frame bodies 2026-07-13 17:38:20 +01:00
Meta Vens
8472330f90 Publish CultCache registry snapshots atomically 2026-07-13 17:22:58 +01:00
Meta Vens
fe55bff2e0 Precommit directory store schema catalog 2026-07-13 17:18:07 +01:00
Meta Vens
edceef54d0 Fix CultCache registry alias ownership 2026-07-13 17:16:41 +01:00
Meta Vens
581c5d0249 Read legacy CultMesh route records 2026-07-13 16:53:22 +01:00
Meta Vens
7e09668b7b Conform CultMesh body network fallback 2026-07-13 16:02:39 +01:00
Meta Vens
0a092d8c7a Own CultMesh SoA frame publication 2026-07-13 15:49:53 +01:00
Meta Vens
2727e325bb Map verified CultMesh CDN bodies without copies 2026-07-13 15:36:50 +01:00
Meta Vens
b9f17a7126 Add verified resumable CultMesh transfers 2026-07-13 15:23:55 +01:00
Meta Vens
fb24e30ab6 Centralize CultMesh authority resolution 2026-07-13 14:00:26 +01:00
Meta Vens
eeb6166e78 Replay unanswered CultMesh subscription intents 2026-07-13 04:43:27 +01:00
Meta Vens
6ffb70cb39 Replay CultMesh subscriptions per physical session 2026-07-13 04:25:03 +01:00
Meta Vens
61257d5a20 Harden CultMesh subscription readiness 2026-07-13 04:19:54 +01:00
Meta Vens
102403efac Complete portable planetary presentation contracts 2026-07-13 02:52:13 +01:00
Meta Vens
886d18800c Treat body capacity as logical capacity 2026-07-13 02:18:25 +01:00
Meta Vens
42fef7cdd7 Build portable planetary field runtime 2026-07-13 02:14:11 +01:00
Meta Vens
c180ec7363 Resolve logical CultMesh RUDP routes 2026-07-13 02:06:22 +01:00
Meta Vens
7737d3f2f4 Merge remote-tracking branch 'origin/codex/cultmesh-document-handles' into codex/cultmesh-reliability-control-plane 2026-07-13 02:04:36 +01:00
Meta Vens
66632fec00 Support shared-memory body capabilities 2026-07-13 02:00:41 +01:00
Meta Vens
fde4d5f01c Add negotiated CultMesh body transport 2026-07-13 01:58:54 +01:00
Meta Vens
15f27173dd Share planetary radial refinement semantics 2026-07-13 00:45:25 +01:00
Meta Vens
e870663fe0 Make spherical erosion diagnostics finite 2026-07-13 00:41:25 +01:00
Meta Vens
34bd95f5b9 Replay live bindings across initial reconnect 2026-07-12 23:27:21 +01:00
Meta Vens
2caf107b7a Roadmap negotiated zero-copy CultMesh bodies 2026-07-12 22:33:18 +01:00
Meta Vens
4d37d1c71f Isolate content reads from live document sessions 2026-07-12 21:55:46 +01:00
Meta Vens
af6adf086d Preserve managed read response budget 2026-07-12 21:52:29 +01:00
Meta Vens
f04e8fe7d7 Batch immutable reads through managed sessions 2026-07-12 21:48:28 +01:00
Meta Vens
66666d4882 Read immutable typed records through managed sessions 2026-07-12 21:27:01 +01:00
Meta Vens
f0429abcb0 Bind live typed collections through CultMesh client 2026-07-12 21:07:21 +01:00
Meta Vens
bb4afc9ba1 Bind live typed documents through CultMesh client 2026-07-12 21:03:55 +01:00
Meta Vens
6403744ada Add identity-first CultMesh application client 2026-07-12 20:56:45 +01:00
Meta Vens
bc8270ecc3 Start CultMesh and Eve getting started series 2026-07-12 20:52:43 +01:00
Meta Vens
c937ec633b Preserve CultMesh sessions across endpoint rotation 2026-07-12 20:52:42 +01:00
Meta Vens
b4e06bc762 Lease schema clients from CultMesh sessions 2026-07-12 20:33:39 +01:00
Meta Vens
1277c6bd2e Route peer exchange through CultMesh sessions 2026-07-12 20:30:55 +01:00
Meta Vens
fb73c7beff Route snapshot sessions through CultMesh sessions 2026-07-12 20:26:36 +01:00
Meta Vens
469b9729d2 Introduce CultMesh session ownership 2026-07-12 20:24:25 +01:00
Meta Vens
a58daa54c7 Allow CultMesh RUDP clients on remote routes 2026-07-12 20:18:13 +01:00
Meta Vens
0a678c6e3a Move Verse discovery ownership into CultMesh 2026-07-12 20:17:57 +01:00
Meta Vens
ddeac557cb Add physical erosion frequency bands 2026-07-12 20:02:45 +01:00
Meta Vens
33c017b284 Make erosion cell hashing cross-device deterministic 2026-07-12 19:57:24 +01:00
Meta Vens
fc65e25fd5 Establish CultMesh reliability failure harness 2026-07-12 19:56:20 +01:00
Meta Vens
430cbc9695 Add faithful advanced erosion filter core 2026-07-12 19:49:33 +01:00
Meta Vens
1f8f832a73 Release CultLib Unity package 1.0.7 2026-07-12 19:43:19 +01:00
Meta Vens
f0b5e1034c Document CultMesh reliability migration 2026-07-12 19:42:27 +01:00
Meta Vens
fbee58eba1 Prevent RUDP snapshot traffic from starving peers 2026-07-12 19:42:16 +01:00
Meta Vens
ab6a2c6456 Complete portable CultMath runtime surface 2026-07-12 19:33:59 +01:00
Meta Vens
a18a7400fb Release CultLib Unity package 1.0.6 2026-07-12 13:10:11 +01:00
Meta Vens
cfd7efb31f Allow live subscriptions without bootstrap snapshots 2026-07-12 13:09:35 +01:00
Meta Vens
cfbb44e1db Release CultLib Unity package 1.0.5 2026-07-12 12:55:25 +01:00
Meta Vens
b6c596866c Bind loopback RUDP clients to loopback 2026-07-12 12:54:49 +01:00
Meta Vens
f1179d11dc Release CultLib Unity package 1.0.4 2026-07-12 12:22:40 +01:00
Meta Vens
b5529435c3 Replicate live typed documents over retained sessions 2026-07-12 12:19:49 +01:00
Meta Vens
4d8d43621d Stream database subscriptions through schema servers 2026-07-12 12:05:31 +01:00
Meta Vens
0e4c5f9bf3 Observe retained snapshot transport health 2026-07-12 11:13:19 +01:00
Meta Vens
71dcfc11e8 Publish RUDP connection state across threads 2026-07-12 11:05:47 +01:00
Meta Vens
5f1583b3c0 Close RUDP schema client sessions cleanly 2026-07-12 10:53:05 +01:00
Meta Vens
ffeb4c1f09 Surface Verse discovery transport failures 2026-07-12 10:43:56 +01:00
Meta Vens
455dcd8f9e Report CultNet failures during connection 2026-07-12 10:38:45 +01:00
Meta Vens
6be0573ffc Surface CultNet background transport failures 2026-07-12 10:33:02 +01:00
Meta Vens
c398bad186 Deliver large scoped snapshots reliably 2026-07-12 08:42:08 +01:00
Meta Vens
eba856cb84 Preserve Unity package import metadata 2026-07-10 12:02:52 +01:00
Meta Vens
5a57e5e8a9 Keep RUDP acknowledgements sequence-neutral 2026-07-10 11:44:04 +01:00
Meta Vens
78383e03f6 Add Rust CultNet operation service host 2026-07-10 11:35:31 +01:00
Meta Vens
aff424e5b6 Reset Rust RUDP sessions for successive peers 2026-07-10 11:33:11 +01:00
Meta Vens
ddb5418e04 Add CultNet RUDP operation service host 2026-07-10 11:20:51 +01:00
Meta Vens
94f95e380d Expose operation envelopes in CultNet CSharp 2026-07-10 11:17:33 +01:00
Meta Vens
631d9bd3ab Mirror operation envelopes in CultNet Rust 2026-07-10 11:14:00 +01:00
Meta Vens
2272aeff08 Add CultNet operation service envelopes 2026-07-10 11:11:28 +01:00
Meta Vens
bf9629000a Package CultMesh NuGet dependency closure 2026-07-10 10:41:39 +01:00
Meta Vens
340df225d4 Align TypeScript RUDP acknowledgement sequencing 2026-07-10 06:56:49 +01:00
Meta Vens
905c5254b4 Reuse CultMesh snapshot connections 2026-07-10 06:37:10 +01:00
Meta Vens
36323c7154 Make CultMesh transport records serializable 2026-07-10 05:05:27 +01:00
Meta Vens
8a4a457dad Build CultLib Unity package 2026-07-10 03:34:49 +01:00
Meta Vens
14a4bc021f Harden directory MessagePack record replacement 2026-07-02 21:00:36 +01:00
Meta Vens
5ce64c69e2 Remove Eve surface DTOs from CultMesh 2026-07-01 15:26:48 +01:00
Meta Vens
396a705654 Add portable Eve surface builder 2026-07-01 11:51:02 +01:00
Meta Vens
743d7688f0 Add CultMesh distributed asset packages 2026-06-28 07:54:00 +01:00
Meta Vens
b155e32541 Add Kotlin reactive document sync 2026-06-28 00:36:59 +01:00
Meta Vens
60f782faa7 Add Rust reactive document sync 2026-06-28 00:31:35 +01:00
Meta Vens
9266bbec22 Add Python reactive document sync 2026-06-28 00:24:46 +01:00
Meta Vens
bbde981082 Add Rust one-call snapshot document sync 2026-06-28 00:17:24 +01:00
Meta Vens
41427d3762 Document typed alias sync ergonomics 2026-06-28 00:14:55 +01:00
Meta Vens
8533da0037 Add Kotlin and Rust alias sync coverage 2026-06-28 00:12:05 +01:00
Meta Vens
a769895e13 Add C# reactive document edge coverage 2026-06-28 00:04:48 +01:00
Meta Vens
6627eb4746 Add Python publication catalog sync 2026-06-28 00:00:15 +01:00
Meta Vens
bcb15d461e Add TS publication catalog sync 2026-06-27 23:57:10 +01:00
Meta Vens
e94cd0973d Add C# publication catalog sync 2026-06-27 23:53:35 +01:00
Meta Vens
47a84f5689 Add C# publication document catalogs 2026-06-27 23:48:53 +01:00
Meta Vens
420e91f4f9 Add Python publication document sync 2026-06-27 23:44:06 +01:00
Meta Vens
abdfff6f4e Add publication sync and reconciliation coverage 2026-06-27 23:37:15 +01:00
Meta Vens
7a335b1a11 Add TS typed publication node sync 2026-06-27 23:27:01 +01:00
Meta Vens
719ea7950a Add Python typed document subscriptions 2026-06-27 23:23:50 +01:00
Meta Vens
76a38ec36e Add C# typed peer snapshot node sync 2026-06-27 23:18:48 +01:00
Meta Vens
743979eca7 Add TS typed peer snapshot node sync 2026-06-27 23:14:18 +01:00
Meta Vens
c1ceb860a0 Add typed Python snapshot fanout coverage 2026-06-27 23:09:16 +01:00
Meta Vens
bd98bfe979 Add Python fanout document sync helper 2026-06-27 23:03:30 +01:00
Meta Vens
1885e2244a Expose Python facade document sync 2026-06-27 22:58:50 +01:00
Meta Vens
854165e943 Add Python one-call document snapshot sync 2026-06-27 22:56:17 +01:00
Meta Vens
672f59dba0 Prefer direct alias remote snapshot handles 2026-06-27 22:53:45 +01:00
Meta Vens
31af506691 Emit Python alias-shaped mesh watch changes 2026-06-27 22:51:18 +01:00
Meta Vens
4f4a4699e5 Resolve Python collection reads by schema alias 2026-06-27 22:40:42 +01:00
Meta Vens
258a710273 Add TS collection catalogs 2026-06-27 22:38:04 +01:00
Meta Vens
964a3ff1f8 Add schema-aware collection catalogs 2026-06-27 22:33:50 +01:00
Meta Vens
a825974e9a Resolve Python shard snapshots by schema alias 2026-06-27 22:29:56 +01:00
Meta Vens
6218900734 Prefer typed peer snapshot aliases by record key 2026-06-27 22:25:15 +01:00
Meta Vens
a3d957dad2 Infer TS document aliases from schema versions 2026-06-27 22:20:18 +01:00
Meta Vens
26159f9cfa Index C# document catalogs by schema id 2026-06-27 22:16:26 +01:00
Meta Vens
d9ffdd77a4 Resolve Python raw deletes by schema alias 2026-06-27 22:13:28 +01:00
Meta Vens
19e2b68fec Resolve C# raw deletes by schema alias 2026-06-27 22:07:31 +01:00
Meta Vens
7661cf7af3 Resolve C# shard routing by schema alias 2026-06-27 22:04:16 +01:00
Meta Vens
54543b5db0 Resolve Python shard discovery filters by schema alias 2026-06-27 22:00:13 +01:00
Meta Vens
94a8c5b104 Resolve TS shard discovery filters by schema alias 2026-06-27 21:57:54 +01:00
Meta Vens
aeb7a96bd3 Resolve TS typed document messages by schema alias 2026-06-27 21:56:02 +01:00
Meta Vens
1b9140da3d Resolve C# subscription filters by schema alias 2026-06-27 21:53:28 +01:00
Meta Vens
c0eacb885b Resolve Python subscription filters by schema alias 2026-06-27 21:50:00 +01:00
Meta Vens
2738f4c2db Resolve TS snapshot filters by schema alias 2026-06-27 21:46:05 +01:00
Meta Vens
bff2e552c8 Resolve Python snapshot discovery by schema alias 2026-06-27 21:42:32 +01:00
Meta Vens
cfed4ea7ef Resolve C# shard catalog filters by schema alias 2026-06-27 21:39:27 +01:00
Meta Vens
4dfa9a6162 Resolve C# snapshot filters by schema alias 2026-06-27 21:37:03 +01:00
Meta Vens
12f819bffe Resolve TS store document aliases by payload 2026-06-27 21:31:48 +01:00
Meta Vens
dcf5f73003 Canonicalize Python payload-resolved schema sync 2026-06-27 21:27:25 +01:00
Meta Vens
8b58208dff Resolve database raw puts by payload schema 2026-06-27 21:22:25 +01:00
Meta Vens
af2bd4a82d Resolve TS snapshot aliases by payload shape 2026-06-27 21:17:07 +01:00
Meta Vens
e430a725ad Apply snapshot aliases by payload schema 2026-06-27 21:12:05 +01:00
Meta Vens
d5f9d2d752 Request snapshot aliases by record key 2026-06-27 21:03:35 +01:00
Meta Vens
b16f49665a Decode snapshot aliases from compatible payloads 2026-06-27 21:00:39 +01:00
Meta Vens
defc416f9f Convert Python node aliases through payload schema 2026-06-27 20:55:17 +01:00
Meta Vens
a2ec06f90e Resolve Python node document aliases transparently 2026-06-27 20:52:26 +01:00
Meta Vens
d62f35fbae Resolve TS node document aliases transparently 2026-06-27 20:46:39 +01:00
Meta Vens
a2b2f8ea8c Add TS node reactive document shortcut 2026-06-27 20:42:09 +01:00
Meta Vens
8cc147d367 Add node-level reactive document shortcuts 2026-06-27 20:35:59 +01:00
Meta Vens
8a88ae547d Expand CultMesh reactive sync edge tests 2026-06-27 20:05:56 +01:00
Meta Vens
e3e07dfc58 Add CultMesh reactive reconciliation deltas 2026-06-27 19:56:08 +01:00
Meta Vens
8cdfdb656c Detect direct edits in CultMesh reactive documents 2026-06-27 19:50:30 +01:00
Meta Vens
5887134193 Expand CultMesh cross-runtime reactive tests 2026-06-27 19:31:59 +01:00
Meta Vens
ce7a5b2158 Expand CultMesh reactive document tests 2026-06-27 19:25:10 +01:00
Meta Vens
ccd5a0e9cf Add managed CultMesh reactive documents 2026-06-27 19:19:09 +01:00
Meta Vens
0af9493ead Track reactive CultMesh reconciliation deltas 2026-06-27 19:06:08 +01:00
Meta Vens
bac41dd5c0 Add reactive CultMesh TS document proxies 2026-06-27 19:02:46 +01:00
Meta Vens
1758a9c52f Add transparent CultMesh document mutation 2026-06-27 18:58:19 +01:00
Meta Vens
6f610a2908 Add transparent CultMesh TS document mutation 2026-06-27 18:50:35 +01:00
Meta Vens
4189ee0811 Add CultMesh TS publication document catalogs 2026-06-27 18:45:52 +01:00
Meta Vens
07d4c4164a Add synced snapshot endpoint facade 2026-06-27 18:37:56 +01:00
Meta Vens
c8fd765aef Add synced snapshot endpoint handles 2026-06-27 18:34:45 +01:00
Meta Vens
3720a8d39d Add snapshot endpoint document bindings 2026-06-27 18:30:17 +01:00
Meta Vens
7bb6d7da8c Add CultMesh multi-document latest helpers 2026-06-27 18:23:26 +01:00
Meta Vens
c45a11e2c8 Add CultMesh document registry helpers 2026-06-27 18:01:12 +01:00
Meta Vens
39e13fdb3c Add typed snapshot sync helpers 2026-06-27 17:50:02 +01:00
Meta Vens
8b48aa512f Add typed snapshot endpoint surface 2026-06-27 17:45:18 +01:00
Meta Vens
a6172aa586 Add CultMesh scoped snapshot helpers 2026-06-27 17:36:35 +01:00
Meta Vens
04561a5bd9 Add CultMesh single-file document helpers 2026-06-27 17:28:15 +01:00
Meta Vens
bcf877d87d Add C# configured publication document handles 2026-06-27 17:10:52 +01:00
Meta Vens
47941b5fb3 Add TS configured publication document handles 2026-06-27 17:07:04 +01:00
Meta Vens
a6537a2d93 Add C# store document handles 2026-06-27 17:03:10 +01:00
Meta Vens
e905aa7a0c Type TS store document handles 2026-06-27 16:49:43 +01:00
Meta Vens
a0509f223b Type TS peer snapshot document handles 2026-06-27 16:45:14 +01:00
Meta Vens
bd095a5837 Request peer snapshot documents by record key 2026-06-27 16:40:42 +01:00
Meta Vens
a57fa1113a Add C# peer snapshot document handles 2026-06-27 16:37:44 +01:00
Meta Vens
52ff632d70 Add CultMesh document catalog mutations 2026-06-27 16:28:42 +01:00
Meta Vens
cb6301e1cf Add TS document prediction submissions 2026-06-27 16:24:08 +01:00
Meta Vens
a08a66a66e Add CultMesh document prediction submissions 2026-06-27 16:20:19 +01:00
Meta Vens
b6d4ebc920 Add synchronous CultMesh document reads 2026-06-27 16:03:10 +01:00
Meta Vens
1753dfbb92 Add CultMesh peer snapshot document handles 2026-06-27 15:56:45 +01:00
Meta Vens
8758a65e8e Add single-file document handles 2026-06-27 15:46:25 +01:00
Meta Vens
8bb85d7c47 Add document surface diagnostics 2026-06-27 15:35:12 +01:00
Meta Vens
a17e10d198 Add CultMesh TS document handles 2026-06-27 15:20:09 +01:00
Meta Vens
eb52b56088 Add CultMesh collection handles 2026-06-27 15:09:36 +01:00
Meta Vens
df079f2cab Add CultMesh document catalogs 2026-06-27 15:01:23 +01:00
Meta Vens
60a3fa9f82 Add cache-backed CultMesh document handles 2026-06-27 14:43:00 +01:00
Meta Vens
81b01ec41e Harden CultNet snapshot fetches 2026-06-27 14:35:54 +01:00
Meta Vens
6e000a71a9 Mirror CultMesh primitives in TypeScript 2026-06-27 14:35:08 +01:00
Meta Vens
4f306edca6 Add shared geometry value primitives 2026-06-27 14:34:59 +01:00
Meta Vens
a0b31b8f2a Recover schema-stamped cache records across runtimes 2026-06-27 14:34:53 +01:00
Meta Vens
c911eb561d Add CultMesh typed document handles 2026-06-27 14:30:59 +01:00
Meta Vens
26d9996d7b Add SIMD SoA batch math kernels 2026-06-17 02:53:02 +01:00
Meta Vens
788a75dba5 Add SPIR-V to Metal tooling 2026-06-11 06:57:22 +01:00
Meta Vens
2c9f81fbf1 Add HLSL SPIR-V tool scripts 2026-06-10 21:30:49 +01:00
Meta Vens
f535801cd0 Promote shader math primitives 2026-06-10 21:22:43 +01:00
Meta Vens
b79b6d047f Broaden Voronoi color spectrum 2026-06-03 04:08:48 +01:00
Meta Vens
bfe356fe92 Add Rust-backed Voronoi batch kernel 2026-06-03 02:58:56 +01:00
Meta Vens
5cef6d951e Add shader-shaped vector constructors and trig intrinsics 2026-05-25 20:33:04 +01:00
Meta Vens
38b79a8d89 Seed HLSL-shaped math library 2026-05-08 01:49:44 +01:00
577 changed files with 113801 additions and 18619 deletions

View file

@ -0,0 +1,97 @@
name: CultMesh portability and performance
on:
push:
branches:
- main
- "codex/**"
pull_request:
workflow_dispatch:
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:
name: ${{ matrix.os }} reactive semantics and leases
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
- 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
cache: npm
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install TypeScript workspace dependencies
run: npm install --no-package-lock
- name: Install Python CultLib packages
run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
- name: Verify explicit reactive scheduling across runtimes
shell: pwsh
run: ./scripts/verify-reactive-document-scaling.ps1
- name: Measure representative reactive workload
shell: pwsh
run: ./scripts/measure-reactive-document-performance.ps1 -Quick -SkipRuntimeBuild
- name: Churn 100,000 distinct document leases
run: >-
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
--filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
--logger "console;verbosity=minimal"

View file

@ -33,10 +33,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: |
packages/cultcache-ts/package-lock.json
packages/cultnet-ts/package-lock.json
- name: Set up Python
uses: actions/setup-python@v5
@ -73,7 +69,7 @@ jobs:
run: npm install --no-package-lock
- name: Install Python package dependencies
run: python -m pip install -e packages/cultcache-py
run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
- name: Build TypeScript workspace dependencies
run: npm run build --workspace packages/cultcache-ts
@ -87,6 +83,24 @@ jobs:
npm run build --workspace packages/cultnet-ts
npx tsc -p packages/cultnet-ts/tsconfig.test.json --pretty false
- name: Verify TypeScript RUDP reliability
run: npm test --workspace packages/cultnet-ts
- name: Verify Python RUDP reliability
run: |
python -m unittest discover -s packages/cultcache-py/tests
python -m unittest discover -s packages/cultnet-py/tests
python -m unittest discover -s packages/cultmesh-py/tests
- name: Verify Rust RUDP reliability
run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp
- name: Verify Rust CultMesh runtime-local surface
run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml
- name: Verify C# RUDP reliability
run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp"
- name: Interop RUDP Python lane
timeout-minutes: 8
run: node --test --test-name-pattern "TypeScript and Python|Python and TypeScript" packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js

146
.github/workflows/publish-packages.yml vendored Normal file
View file

@ -0,0 +1,146 @@
name: Publish packages
# Non-Rust consumers cannot take a package from a subdirectory of a git repo the
# way cargo can, which is why they ended up vendoring forks instead. This gives
# them a versioned artifact to depend on so CultLib stays the only source.
#
# Rust needs nothing here: cargo git dependencies pin a CultLib revision
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
# consume the core stack.
#
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
# manifest; the workflow refuses the publish otherwise rather than shipping a
# mislabelled artifact.
on:
push:
tags:
- "cultcache-ts-v*"
- "cultcache-py-v*"
- "cultnet-py-v*"
- "cultmesh-py-v*"
workflow_dispatch:
inputs:
package:
description: Package to build and test (publishes nothing on manual runs)
required: true
type: choice
options:
- cultcache-ts
- cultcache-py
- cultnet-py
- cultmesh-py
permissions:
contents: read
jobs:
cultcache-ts:
name: "@gamecult/cultcache-ts to npm"
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out CultLib
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 24.x
registry-url: https://registry.npmjs.org
- name: Install workspace dependencies
run: npm ci
- name: Verify the tag matches the manifest version
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
run: |
manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")"
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
if [ "$manifest" != "$tagged" ]; then
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
exit 1
fi
- name: Build and test
working-directory: packages/cultcache-ts
run: npm test
# Scoped packages default to restricted even on public npm, and this is
# MIT like the rest of the repo, so say public explicitly.
- name: Publish
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
working-directory: packages/cultcache-ts
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# The three Python packages layer cultcache-py < cultnet-py < cultmesh-py, the
# same way the Rust crates and npm packages do. One job shape serves all
# three; the matrix entry names the package and the tag prefix that releases it.
python:
name: ${{ matrix.package }} to PyPI
if: startsWith(github.ref, 'refs/tags/cultcache-py-v') || startsWith(github.ref, 'refs/tags/cultnet-py-v') || startsWith(github.ref, 'refs/tags/cultmesh-py-v') || endsWith(inputs.package, '-py')
strategy:
fail-fast: false
matrix:
package: [cultcache-py, cultnet-py, cultmesh-py]
runs-on: ubuntu-latest
timeout-minutes: 20
environment: pypi
permissions:
contents: read
# PyPI trusted publishing exchanges this for a short-lived token, so no
# long-lived API token is stored in the repository.
id-token: write
steps:
- name: Skip packages this run does not name
id: selected
run: |
if [ "${GITHUB_REF_NAME#${{ matrix.package }}-v}" != "$GITHUB_REF_NAME" ] || [ "${{ inputs.package }}" = "${{ matrix.package }}" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check out CultLib
if: steps.selected.outputs.run == 'true'
uses: actions/checkout@v4
- name: Set up Python
if: steps.selected.outputs.run == 'true'
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Verify the tag matches the manifest version
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")"
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
if [ "$manifest" != "$tagged" ]; then
echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
exit 1
fi
# Lower layers install from source so a release of one package is tested
# against the sibling revision it was cut with, not whatever PyPI has.
- name: Install and test
if: steps.selected.outputs.run == 'true'
run: |
python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
python -m unittest discover -s packages/${{ matrix.package }}/tests
- name: Build sdist and wheel
if: steps.selected.outputs.run == 'true'
run: |
python -m pip install --upgrade build
python -m build packages/${{ matrix.package }}
- name: Publish
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/${{ matrix.package }}/dist

16
.gitignore vendored
View file

@ -303,10 +303,22 @@ node_modules/
!packages/cultmesh-ts/**
!packages/cultcache-py/
!packages/cultcache-py/**
!packages/cultnet-py/
!packages/cultnet-py/**
!packages/cultmesh-py/
!packages/cultmesh-py/**
!packages/cultcache-rs/
!packages/cultcache-rs/**
!packages/cultnet-rs/
!packages/cultnet-rs/**
!packages/cultmesh-rs/
!packages/cultmesh-rs/**
!packages/cultmath/
!packages/cultmath/**
!packages/cultmesh-browser/
!packages/cultmesh-browser/**
!packages/cultmesh-kotlin/
!packages/cultmesh-kotlin/**
packages/**/node_modules/
packages/**/dist/
packages/**/dist-test/
@ -504,3 +516,7 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
# Python package build output
packages/*-py/build/
*.egg-info/

View file

@ -1,7 +0,0 @@
{
"status": "starting",
"startedAt": "2026-06-14T19:46:12.715Z",
"stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc",
"launcher": "repo_face_heartbeat",
"reason": "repo_face_stale_short_term_memory"
}

3
CLAUDE.md Normal file
View file

@ -0,0 +1,3 @@
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
@AGENTS.md

View file

@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}"
@ -31,6 +27,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj", "{B67042B8-1135-4E54-B1EB-D45342C720D7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}"
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}"
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}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "packages\cultmath\src\CultMath\CultMath.csproj", "{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\cultmath\tests\CultMath.Tests\CultMath.Tests.csproj", "{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -77,30 +95,6 @@ Global
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU
{0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU
{DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -185,6 +179,102 @@ Global
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x64.Build.0 = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.ActiveCfg = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Release|x86.Build.0 = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.ActiveCfg = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x64.Build.0 = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.ActiveCfg = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Debug|x86.Build.0 = Debug|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|Any CPU.Build.0 = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.ActiveCfg = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x64.Build.0 = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.ActiveCfg = Release|Any CPU
{B67042B8-1135-4E54-B1EB-D45342C720D7}.Release|x86.Build.0 = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.ActiveCfg = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x64.Build.0 = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.ActiveCfg = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Debug|x86.Build.0 = Debug|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|Any CPU.Build.0 = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.ActiveCfg = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU
{B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = 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.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.Build.0 = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.Build.0 = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.ActiveCfg = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.Build.0 = Debug|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.Build.0 = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.ActiveCfg = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.Build.0 = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.ActiveCfg = Release|Any CPU
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.Build.0 = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.ActiveCfg = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.Build.0 = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.ActiveCfg = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.Build.0 = Debug|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.Build.0 = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.ActiveCfg = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.Build.0 = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.ActiveCfg = Release|Any CPU
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.Build.0 = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.ActiveCfg = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.Build.0 = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.ActiveCfg = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.Build.0 = Debug|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.Build.0 = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.ActiveCfg = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU
{D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -193,8 +283,6 @@ Global
{72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
@ -202,5 +290,14 @@ Global
{94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{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}
{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
{D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
EndGlobalSection
EndGlobal

View file

@ -1,132 +0,0 @@

// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>CultCache: Concurrent polymorphic cache with modular backing stores for .NET</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
</ItemGroup>
</Project>
// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>File-based MessagePack backing store for CultCache</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<ProjectReference Include="..\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<PackageReference Include="MessagePack" Version="3.1.4" />
</ItemGroup>
</Project>
// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- This tells MSBuild we are producing an analyzer -->
<IsRoslynComponent>true</IsRoslynComponent>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput>
<!-- Optional: Pack this as a NuGet analyzer -->
<Description>Source generator that emits MessagePack formatters for DatabaseEntry subclasses.</Description>
<PackageTags>MessagePack;SourceGenerator;DatabaseEntry</PackageTags>
</PropertyGroup>
<ItemGroup>
<!-- Core Roslyn packages for incremental generators -->
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.10.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Mark this output as an analyzer -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Simple pluggable logging interface for CultLib</Description>
</PropertyGroup>
</Project>
// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>Unity integration for GameCult.Logging</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
<PackageReference Include="R3" Version="1.3.0" />
</ItemGroup>
</Project>
// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>CultNet: Secure, high-performance messaging for games/servers</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
<ProjectReference Include="..\GameCult.Logging\GameCult.Logging.csproj" />
<ProjectReference Include="..\GameCult.Caching\GameCult.Caching.csproj" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="LiteNetLib" Version="1.3.1" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
</ItemGroup>
</Project>
// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<RootNamespace>GameCult.Networking.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" />
</ItemGroup>
<ItemGroup>
<!-- NUnit framework -->
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NUnit" Version="4.*" />
<PackageReference Include="NUnit3TestAdapter" Version="4.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="FluentAssertions" Version="6.*" />
<PackageReference Include="coverlet.collector" Version="6.*">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -14,7 +14,8 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.3</Version>
<Version>1.0.35</Version>
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
<!-- Symbols/debugging -->
<EmbedUntrackedSources>true</EmbedUntrackedSources>

111
README.md
View file

@ -11,8 +11,9 @@ The stack is less a single product than a set of capabilities:
- logging primitives and implementations
- CultCache typed documents, `.cc` persistence, and SoA memory access
- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and
CultLib's native cross-runtime RUDP pipe
- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded
ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP
pipe
- CultMesh distributed realtime database, Verse discovery, authority leases,
shard replication, and witness-authoritative simulation consensus
- typed geometry domain/chunk documents for distributed CSG and LOD streaming
@ -37,8 +38,9 @@ and pretending the label makes it furniture.
| Job | Start With | Owns | Use When | Do Not Use It For |
| --- | --- | --- | --- | --- |
| Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus |
| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority |
| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
| Portable math | `GameCult.Math` / CultMath | Shader-shaped numeric primitives, deterministic random, noise, and CPU/GPU parity helpers | Shared simulation and geometry code must not depend on an engine's math or RNG types | Domain-specific geometry, rendering, or world authority |
| Procedural geometry | `GameCult.Geometry` / CultGeometry | Isosurface extraction plus CultCache-native domains, LOD requests, selected cuts, and mesh artifacts | C#, Rust, Godot, or remote workers need shared geometry algorithms and typed results | Transport policy, peer discovery, or gameplay authority |
| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
| Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract |
| Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy |
| Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon |
@ -46,8 +48,10 @@ and pretending the label makes it furniture.
Quick rule:
- Choose CultCache when the problem is "how do I model and persist typed state?"
- Choose GameCult.Geometry when the problem is "how do geometry workers share
domain trees, LOD build requests, and mesh chunks as typed state?"
- Choose GameCult.Math when the problem is "which portable numeric and random
semantics do these runtimes share?"
- Choose GameCult.Geometry when the problem is "how do geometry workers build
and share engine-neutral geometry?"
- Choose CultNet when the problem is "how do peers exchange authenticated,
schema-aware database messages?"
- Choose CultMesh when the problem is "how does a game join a Verse and share
@ -62,8 +66,25 @@ the stack. If a design needs another peer-to-peer category, first check whether
it is really Verse policy, peer exchange, or shard authority wearing a fake
mustache.
If you want the smallest durable "open node -> put typed doc -> get typed doc"
path, start with
If you want to build an application rather than tour the component drawer,
start with the maintained
[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
series. Its first executable package-consumer checkpoint is
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
The real-process browser continuation is
[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
One documented command runs both layers:
```powershell
pwsh -File ./scripts/verify-eve-getting-started.ps1
```
The implemented browser client boundary and remaining deployed-infrastructure
gates are mapped explicitly in
[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md).
If you want only the smallest durable "open node -> put typed doc -> get typed doc"
path, use
[`src/GameCult.Mesh/docs/durable-node-quickstart.md`](src/GameCult.Mesh/docs/durable-node-quickstart.md).
If you want the lower-level "typed local document -> raw wire document ->
mesh/runtime watch surface" handoff, follow it with
@ -77,10 +98,10 @@ The solution includes:
- `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions
- `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache
- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache
- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models
- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects
- `GameCult.Geometry`: CultCache-native geometry domain, selected-cut, and chunk artifact documents for VibeGeometry/Fensalir-style pipelines
- `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers
- `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents
- `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions
- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients
- `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus
- `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior
- `GameCult.Networking.Tests`: NUnit tests for networking behavior
@ -88,7 +109,10 @@ The solution includes:
- `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling
- `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests
- `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot
- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity
- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route
- `packages/cultcache-py`: Python CultCache with CultCache v1 wire parity
- `packages/cultnet-py`: Python CultNet contracts, framing, RUDP transport, replication, and interop peer
- `packages/cultmesh-py`: Python CultMesh local node, discovery, sessions, local server, and daemon
- `packages/cultcache-rs`: Rust CultCache and derive macro
- `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer
- `packages/cultmesh-kotlin`: Kotlin/JVM CultMesh and CultNet surface for Android-adjacent runtimes
@ -102,10 +126,9 @@ src/
GameCult.Caching/
GameCult.Caching.MessagePack/
GameCult.Caching.NewtonsoftJson/
GameCult.Caching.MessagePack.Generator/
GameCult.Caching.MessagePack.Analyzers/
GameCult.Geometry/
GameCult.Networking/
GameCult.Networking.WebSockets/
GameCult.Mesh/
GameCult.Unity/
tests/
@ -113,11 +136,15 @@ tests/
GameCult.Geometry.Tests/
GameCult.Networking.Tests/
packages/
cultmath/
cultcache-ts/
cultnet-ts/
cultmesh-ts/
cultmesh-browser/
cultmesh-kotlin/
cultcache-py/
cultnet-py/
cultmesh-py/
cultcache-rs/
cultnet-rs/
```
@ -141,8 +168,22 @@ npm test --workspace packages/cultcache-ts
npm test --workspace packages/cultnet-ts
npm run test:interop --workspace packages/cultnet-ts
npm test --workspace packages/cultmesh-ts
npm test --workspace packages/cultmesh-browser
```
Measured reactive, retained-session, and shared-memory hot-body gates:
```powershell
pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick
pwsh -File ./scripts/verify-eve-getting-started.ps1
```
The first command measures the same 16 KiB document workload in C#,
TypeScript, and Python and includes a mapped hot-body probe with explicit copy
telemetry on Windows named maps and Unix file-backed maps. The second includes
10,000 real WebSocket operations on one retained
identity-owned C# session after provider route replacement.
Rust package tests:
```powershell
@ -192,41 +233,23 @@ public class ItemData : DatabaseEntry, INamedEntry
- backing stores are persistence adapters
- indexes and name maps are maintained inside the cache, not inside the store
### Important: Multiple Backing Stores
### Multiple Backing Stores
When multiple backing stores are added, behavior depends on how they are registered.
If a store is added with domain types:
Every document type has exactly one home store. There are no mirrors.
```csharp
cache.AddBackingStore(playerStore, typeof(PlayerData));
cache.AddBackingStore(settingsStore, typeof(AppSettings));
cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction));
cache.AddBackingStore(runStore, typeof(SavedGame));
```
then that store becomes the direct persistence target for those types.
- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store
- at most one untyped store; a type claimed by two stores is an error
- a write with no home is an error once any store is attached
- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded
- loading never writes: opening and flushing without a mutation leaves every file byte-identical
- a read-only store refuses writes and is skipped by flush
If a store is added without domain types:
```csharp
cache.AddBackingStore(primaryStore);
cache.AddBackingStore(mirrorStore);
```
then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events.
Implications:
- order matters for generic stores
- `AddAsync` writes to the type-specific store when one exists
- otherwise `AddAsync` writes to the first generic store
- later generic stores do not become co-primaries; they mirror earlier stores
- `PullAllBackingStoresAsync` pulls from every registered store
Recommended patterns:
- use one generic primary store if you want simple persistence
- use domain-specific stores when different entry types belong in different persistence layers
- treat additional generic stores as mirrors or downstream replicas, not independent write targets
The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache.
## Example: Cache + Networking
@ -322,8 +345,6 @@ Each subproject has a local README with package-specific detail:
- [GameCult.Caching](src/GameCult.Caching/README.md)
- [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md)
- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md)
- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md)
- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md)
- [GameCult.Geometry](src/GameCult.Geometry/README.md)
- [GameCult.Networking](src/GameCult.Networking/README.md)
- [GameCult.Mesh](src/GameCult.Mesh/README.md)

View file

@ -0,0 +1,61 @@
# CultMesh Provider Session
## Authority map
- Owner: the provider-session broker owns registration, active lease fencing,
accepted publication membership, command retention, receipt acceptance, and
withdrawal.
- Inputs: a physical CultNet session, an explicit registration-authorization
decision, correlated typed operations, the broker clock, provider commands,
and persistence callbacks. The current RUDP adapter does not itself provide
an authenticated claim.
- Outputs: leases, application acceptances, accepted raw documents, pushed
typed commands, persisted receipts, and deterministic removals.
- Derived state: socket sessions, retry schedules, connection health, and
provider projections are observations. They do not authorize publications.
- Forbidden writers: RUDP packet acknowledgements, source addresses, direct raw
document callbacks, renderer-owned catalogs, and arrival-time freshness maps
cannot create accepted provider truth.
- Shared paths: initial connect and reconnect use `provider.register`; initial,
replayed, and live documents use `provider.publication.put`; explicit
withdrawal and lease expiry remove the broker-owned publication set; command
retry ends only when `provider.receipt.put` is accepted.
- Cut line: a receiver must stop accepting unleased provider raw puts before it
claims this protocol as its ingress authority.
## Wire contract
Provider lifecycle operations use the reliable ordered CultNet schema channel
and the existing `cultnet.operation_request.v0` / `cultnet.operation_response.v0`
envelopes with service id `gamecult.mesh.provider_session`. Inner payloads are
camel-case MessagePack maps encoded as `messagepack-base64`.
The v1 operations are:
| Operation | Request payload | Successful payload |
| --- | --- | --- |
| `provider.register` | `gamecult.mesh.provider_registration.v1` | `gamecult.mesh.provider_lease.v1` |
| `provider.renew` | `gamecult.mesh.provider_lease_renewal.v1` | `gamecult.mesh.provider_lease.v1` |
| `provider.publication.put` | `gamecult.mesh.provider_publication_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
| `provider.publication.delete` | `gamecult.mesh.provider_publication_delete.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
| `provider.receipt.put` | `gamecult.mesh.provider_receipt_put.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
| `provider.withdraw` | `gamecult.mesh.provider_withdrawal.v1` | `gamecult.mesh.provider_mutation_acceptance.v1` |
Broker-to-provider commands are raw typed documents using
`gamecult.mesh.provider_command.v1`. Asset bodies do not travel inside these
operation payloads.
Responses use `ok`, `conflict`, `expired`, `denied`, or `invalid`. A transport
ACK is never an application acceptance. Renewal issues a new lease id and
immediately fences the previous id. The broker derives the set removed by full
withdrawal; a provider cannot use a supplied list to hide owned publications.
Public deployment additionally requires a CultNet authentication claim that
authorizes the provider identity. Until that claim is wired and checked, the
TypeScript RUDP adapter is a private-development transport.
`CultMeshProviderSessionBroker` is an in-memory reference broker and socket
proof. Its command and receipt retention does not survive process restart.
Production receivers must persist that control-plane state atomically with
accepted publication ownership; Odin must not substitute these volatile maps
for its durable broker authority.

View file

@ -31,7 +31,7 @@ CultNet owns cross-runtime transport semantics:
| C# interop peer | Schema-v0 MessagePack over TCP-framed or shared RUDP transport | UDP multicast probe/announce with TCP and RUDP transport profiles | Test harness transport parity |
| TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner |
| Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner |
| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
| Python `cultnet-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
| Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof |
The live split is therefore: payload language and the native RUDP packet/session
@ -258,11 +258,19 @@ Current progress:
echo against C#/Kotlin/Python/Rust UDP peers; local runtime tests prove timeout
state transitions.
- TypeScript, C#, Rust, Python, and Kotlin now share bounded reliable-send
backpressure for RUDP sessions. `maxPendingReliablePackets` is advertised on
RUDP transport-profile channels and enforced by the session before reliable
connect, accept, single-packet sends, or fragmented sends enqueue anything;
TCP-framed profiles do not publish the field because they do not own the RUDP
pending-reliable queue.
backpressure for RUDP sessions. At most 32 reliable packets are admitted to
the wire at once; later fragments remain in a session-owned FIFO and
acknowledgements promote the next packets. Receivers emit a cumulative ACK
for packets inside the 32-sequence ACK horizon and an exact ACK for an older
retransmit, so unrelated unreliable traffic cannot strand reliable work.
This keeps the send frontier well
inside the 4,096-sequence receive history instead of blasting an entire large
document and hoping the UDP buffers impersonate flow control.
`maxPendingReliablePackets` bounds both queued and in-flight packets before a
reliable frame is accepted. Socket transports expose an explicit reliable
flush where the runtime supports one, so callers that require confirmed
transport delivery do not substitute a fixed sleep. TCP-framed profiles do
not publish the field because they do not own the RUDP pending-reliable queue.
- TypeScript, C#, Rust, Python, and Kotlin now share a portable
`cultnet.reconnect_policy.v0` document and deterministic exponential delay
helper, and RUDP transport profiles advertise that policy under

23
docs/nuget-packaging.md Normal file
View file

@ -0,0 +1,23 @@
# NuGet Packaging
CultLib publishes the managed dependency graph as separate packages. The leaf
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
CultNet, and CultCache ownership boundaries.
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\pack-nuget.ps1
```
The command creates a local feed under `artifacts/nuget`, verifies that every
internal dependency package exists at the same version, then restores and runs
a clean .NET consumer using only `PackageReference Include="GameCult.Mesh"`.
The Unity distribution remains `org.gamecult.cultlib`, built by
`scripts/build-unity-package.ps1`, because Unity consumes the managed assembly
closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
```

32
docs/parked-features.md Normal file
View file

@ -0,0 +1,32 @@
# Parked Features
A parked feature was built for a stated reason, has no consumer, and was
removed from the tree. Its last working form lives at an annotated tag so it
can be restored instead of reinvented.
## CultCache SoA table
- Tag: `parked/cultcache-soa`
- Intent: ECS-style structure-of-arrays columns over cached documents, for
hot-loop performance without giving up POCO document ergonomics.
- Span at the tag: `src/GameCult.Caching/CultManagedDocument.cs:133-414`
(`CultSoaTable<T>`, `CultSoaColumn<T>`, `CultCacheSoaStore`,
`CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa<T>()` and the
`_soa` field with its upsert/remove hooks in `CultCache.cs`; the SoA tests in
`tests/GameCult.Caching.Tests/BackingStoreTests.cs` and
`tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs`.
- Managed document: `CultManagedDocument<T>` (`CultManagedDocument.cs:72-131` at
the tag) with `CultCache.Document<T>` and `CultNetDatabase.Document<T>`, the
reactive POCO handle added with the SoA storage in 5428870 "Make SoA
cache-managed document storage". Same tag, same intent: the document stays a
POCO while the cache owns its columnar storage.
- Tag message correction: the `parked/cultcache-soa` tag message says
`CultManagedDocument<T>` was "deleted separately, not parked". This note
supersedes it: `CultManagedDocument<T>` is parked under the same tag and is
restored alongside SoA. The tag itself is left as written.
- Consumers when parked: none outside tests.
- Restore: `git show parked/cultcache-soa:src/GameCult.Caching/CultManagedDocument.cs`,
take the SoA types and `CultManagedDocument<T>`, restore both `Document<T>`
openers from `CultCache.cs` and `CultNetDatabase.cs` at the tag, and re-hook a
`CultCacheSoaStore` into the cache's single
admission and eviction paths (`Admit`/`Evict`).

View file

@ -0,0 +1,68 @@
# The encoding of a persisted CultCache record is part of its contract
Changing how a stored record serializes is never a flag flip in this estate.
Consumers verify canonicality by **re-encoding what they decoded and comparing
bytes**, so a record that reads back correctly but re-encodes differently is
refused — and the refusal happens on live data, at startup or on the next read.
Three guards, three separate stores, one idiom:
| store | guard | refusal |
|---|---|---|
| Idunn runtime projection | `Idunn/src/drivers.rs:3611` | `service trust-anchor projection is noncanonical` |
| Idunn deployment brake | `Idunn/src/control_plane.rs` (`read_deployment_brake`) | `deployment brake is noncanonical or keyed by another authority` |
| Idunn trust bindings | `Idunn/src/provisioning.rs:546`, `:630` | `trust store contains a noncanonical or mismatched binding` |
Idunn's control store applies the same rule to its own transactions. Assume the
guard exists and go looking for it before changing any persisted record's
serialization; three independent instances is a house pattern, not a
coincidence.
## What this means for `#[cultcache(key = N, bytes)]`
The attribute switches a `Vec<u8>` slot from a MessagePack array-of-integers
(serde's default) to a real `bin`. Measured, not assumed
(`packages/cultnet-rs/tests/byte_slot_migration.rs`):
- **Reading stays compatible in both directions.** `serde_bytes::ByteBuf`
accepts an array marker, and a plain `Vec<u8>` accepts `bin`. No dual-read
path is needed, and reverting a binary is survivable.
- **Reproduction does not.** A record written before the flip can be read but
can never re-encode to the bytes it was stored as, and its `canonical_sha256`
moves with it.
So the cost lands entirely on the guards above, not on readability. A field with
persisted records must flip the attribute **and** rewrite its store in the same
change, one target at a time, and never during a deployment — the projection
store is what the observer reads, and refusing it takes the observer down.
A field with no live producer is free. Check for one before assuming a
migration: `IdunnAuthenticatedProviderHealthProjectionRecord` is constructed
only inside a test module, so its byte field costs nothing to convert.
Size, for whether it is worth doing at all: 406 bytes as an array against 278 as
`bin` for a 256-byte payload, and 1234 against 851 for an 848-byte one. About
1.45x on every byte, which matters on a media path and is noise on a control
record.
## `Option<Vec<u8>>` cannot take the attribute yet
The derive emits `serde_bytes::Bytes::new(&self.field)`, which needs `&[u8]`,
and builds the field via `.into_vec()`, which yields `Vec<u8>`. Both are type
errors against an `Option<Vec<u8>>`, so the derive fails loudly rather than
collapsing "absent slot" into "unsigned". `IdunnDeploymentBrakeRecord.signature`
needs an `Option`-aware path in `cultcache-rs-derive` before it can move.
## Fields validated for shape and produced by nothing
`trust_binding_sha256` is declared on two records with different schemas
(`idunn.authenticated_provider_health_projection.v1` in cultnet-rs,
`idunn.authenticated_daemon_health_admission.v1` in odin-core), shape-validated
in both, and computed by nothing in CultLib, Idunn or Odin.
Do not assume that means "omission". It may be a fossil — a contract whose
producer was deleted. The estate has form: Muninn's receiver runs a Reed-Solomon
audio FEC path against parity shards the Rust sender has never emitted, while an
acceptance ledger describes that FEC as having worked. Settling which requires
reading history, not source, and the answer changes whether the right move is to
implement the field or delete it.

View file

@ -1,7 +1,7 @@
# Python Runtime Parity Map
This file is the current audit map for `cultcache-py` as a Python runtime for
CultCache, CultNet, and CultMesh. It is evidence, not confetti.
This file is the current audit map for the Python runtime packages
`cultcache-py`, `cultnet-py`, and `cultmesh-py`. It is evidence, not confetti.
## Proven Surfaces
@ -255,14 +255,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
individual discovery, peer exchange, or simulation surfaces unserved without
advertising disabled capabilities.
- Python package health:
`cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh
`cultmesh-py-verify --json` checks the public CultCache/CultNet/CultMesh
export surface declared by each package `__all__`, `py.typed` markers, a live
local CultMesh framed-wire smoke over hello/schema/snapshot/shard
catalog/shard log, typed peer-error export availability, peer-advertised
mutation contracts, payload schema wire contracts, simulation capability
truth, and benchmark sanity for Python-owned hot paths.
- Python/C# public cache baseline:
`cultcache_py.compare_csharp` runs Python and C# `CultCache` upsert/get
`cultnet_py.compare_csharp` runs Python and C# `CultCache` upsert/get
benchmarks with the same operation names and reports median Python-to-C# ratios
from three samples by default. It also emits per-metric threshold checks and an
overall parity status so performance claims have an explicit evidence gate.
@ -275,12 +275,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
## Local Python Gates
```powershell
$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src"
python -m unittest discover -s packages\cultcache-py\tests
python -m cultcache_py.verify --json
python -m cultcache_py.benchmark --records 1000 --json
python -m cultcache_py.compare_csharp --records 1000 --samples 3 --json
python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py
python -m unittest discover -s packages\cultnet-py\tests
python -m unittest discover -s packages\cultmesh-py\tests
python -m cultmesh_py.verify --json
python -m cultnet_py.benchmark --records 1000 --json
python -m cultnet_py.compare_csharp --records 1000 --samples 3 --json
python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py packages\cultnet-py packages\cultmesh-py
```
## Cross-Runtime Gate

View file

@ -21,20 +21,23 @@ Cross-runtime parity has three layers:
transport helpers instead of assembling raw maps and channel strings.
SoA performance parity is deliberately not a cross-runtime requirement. C#
owns the CPU-local SoA table and shared-memory streaming body path. Other
runtimes may add native hot paths when their runtime role earns them, but they
do not need to mimic Unity/C# memory layout to speak CultNet or participate in
CultMesh.
owns the shared-memory streaming body path. The C# CultCache SoA table (ECS-style
structure-of-arrays columns over cached documents) is parked at the tag
`parked/cultcache-soa`: it was built for hot-loop performance, had no consumer
outside tests, and is restorable from that tag (`docs/parked-features.md`).
It is not a supported surface in the current tree. Other runtimes may add native hot paths when their
runtime role earns them, but they do not need to mimic Unity/C# memory layout
to speak CultNet or participate in CultMesh.
## Current Targeted Runtimes
| Runtime | Role | Claimed parity | Not claimed |
| --- | --- | --- | --- |
| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |
| Kotlin | JVM/Android client substrate | Typed MessagePack cache, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim |
| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout |
## Evidence Gates
@ -52,7 +55,7 @@ CultMesh.
`reconnectPolicy` profile field from C#, Rust, Python, and Kotlin peers.
Runtime-local tests cover the deterministic delay helper and reconnect
controller in C#, TypeScript, Rust, Python, and Kotlin.
- `packages/cultcache-py/PARITY.md` is the Python-specific audit ledger. Its
- `docs/python-runtime-parity.md` is the Python-specific audit ledger. Its
"Still Not Claimed" section is part of the contract, not an apology.
- `docs/cultnet-transport-parity.md` is the transport ownership map. It tracks
the remaining work to lower older LiteNetLib/WebSocket bodies behind the
@ -85,6 +88,16 @@ server body, is deliberately not made.
backfilling C# server responsibilities it should not own. Kotlin WebSocket
and RUDP adapters now share one `CultNetSchemaMessageTransport` helper shape
for schema/shard catalog request and sync flows.
- Bring `packages/cultmesh-rs` under the wire claim. It arrived from a consumer repo where it grew
outside any conformance harness, and its stream, negotiation, and shared-memory frame-ring
surfaces have never been checked against the browser, Kotlin, and TypeScript CultMesh bodies
they must interoperate with. Runtime-local tests pass; that is not a parity claim.
- Carry the RUDP receive-path bounds to the other runtimes. Rust now caps tracked ordered
and sequenced channels, caps the out-of-order hold buffer by frames and bytes, refuses a
packet far ahead of the highest received so a forged sequence cannot starve a session, and
reports reliable sequence exhaustion instead of aborting. C#, TypeScript, Python and Kotlin
still accept all of it. These are refusals rather than wire-format changes, so the interop
lanes stay green either way -- which is exactly why the gap will not announce itself.
- Keep performance claims evidence-bound: C# owns SoA; Python has benchmark
comparison gates; other runtimes should advertise measured native hot paths
only when those paths exist.

View file

@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.24)
project(gamecult_mesh_quic_native LANGUAGES CXX)
if(NOT WIN32)
message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only")
endif()
if(NOT DEFINED MSQUIC_ROOT)
message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory")
endif()
add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp)
target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20)
target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX)
target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include")
target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64")
target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt)
set_target_properties(gamecult_mesh_quic_native PROPERTIES
OUTPUT_NAME "gamecult_mesh_quic_native"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")

View file

@ -0,0 +1,10 @@
# GameCult.Mesh.Quic.Native
This Windows desktop bridge keeps MsQuic's callback-heavy C API below the
managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate
pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame
payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`.
The build consumes Microsoft's pinned
`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain
Aetheria-specific code.

View file

@ -0,0 +1,440 @@
#include <windows.h>
#include <wincrypt.h>
#include <bcrypt.h>
#include <msquic.h>
#include <array>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <deque>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <vector>
#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS)
#define CULTMESH_API extern "C" __declspec(dllexport)
#else
#define CULTMESH_API extern "C" __declspec(dllimport)
#endif
namespace {
constexpr uint32_t kApiVersion = 2;
constexpr uint64_t kConnectionCloseCode = 0x43554c54;
constexpr uint64_t kStreamAbortCode = 0x53544154;
constexpr uint8_t kReliableStream = 1;
constexpr uint8_t kLatestOnlyStream = 2;
constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u);
constexpr char kAlpn[] = "cultmesh-state-v1";
enum class ClientState : int32_t {
Connecting = 0,
Connected = 1,
Failed = 2,
Closed = 3,
};
struct Client;
struct StreamContext {
explicit StreamContext(Client* client) : owner(client) {}
Client* owner;
std::vector<uint8_t> buffered;
uint8_t kind = 0;
bool has_kind = false;
};
struct Client {
const QUIC_API_TABLE* api = nullptr;
HQUIC registration = nullptr;
HQUIC configuration = nullptr;
HQUIC connection = nullptr;
std::array<uint8_t, 32> certificate_pin{};
std::atomic<ClientState> state{ClientState::Connecting};
std::atomic<bool> closing{false};
std::mutex gate;
std::deque<std::vector<uint8_t>> received;
std::string error;
std::string trace;
};
void AppendTrace(Client* client, const std::string& event) {
if (client == nullptr || client->state.load() == ClientState::Connected) return;
std::lock_guard<std::mutex> lock(client->gate);
if (!client->trace.empty()) client->trace += ",";
client->trace += event;
}
std::string TraceSnapshot(Client* client) {
if (client == nullptr) return "none";
std::lock_guard<std::mutex> lock(client->gate);
return client->trace.empty() ? "none" : client->trace;
}
void SetFailure(Client* client, const std::string& message) {
if (client == nullptr || client->closing.load()) return;
{
std::lock_guard<std::mutex> lock(client->gate);
if (client->error.empty()) client->error = message;
}
client->state.store(ClientState::Failed);
}
bool ParseHexNibble(char value, uint8_t& nibble) {
if (value >= '0' && value <= '9') nibble = static_cast<uint8_t>(value - '0');
else if (value >= 'a' && value <= 'f') nibble = static_cast<uint8_t>(value - 'a' + 10);
else if (value >= 'A' && value <= 'F') nibble = static_cast<uint8_t>(value - 'A' + 10);
else return false;
return true;
}
bool ParsePin(const char* value, std::array<uint8_t, 32>& pin) {
if (value == nullptr || std::strlen(value) != 64) return false;
for (size_t index = 0; index < pin.size(); ++index) {
uint8_t high = 0;
uint8_t low = 0;
if (!ParseHexNibble(value[index * 2], high) || !ParseHexNibble(value[(index * 2) + 1], low))
return false;
pin[index] = static_cast<uint8_t>((high << 4) | low);
}
return true;
}
uint32_t ReadUInt32LittleEndian(const uint8_t* value) {
return static_cast<uint32_t>(value[0]) |
(static_cast<uint32_t>(value[1]) << 8) |
(static_cast<uint32_t>(value[2]) << 16) |
(static_cast<uint32_t>(value[3]) << 24);
}
void FailStream(StreamContext* stream, const char* message) {
SetFailure(stream->owner, message);
if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr)
stream->owner->api->ConnectionShutdown(
stream->owner->connection,
QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT,
kStreamAbortCode);
}
bool ConsumeFrames(StreamContext* stream) {
auto& bytes = stream->buffered;
if (!stream->has_kind) {
if (bytes.empty()) return true;
stream->kind = bytes.front();
bytes.erase(bytes.begin());
stream->has_kind = true;
if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) {
FailStream(stream, "CultMesh QUIC stream kind is invalid.");
return false;
}
}
while (bytes.size() >= sizeof(uint32_t)) {
const uint32_t length = ReadUInt32LittleEndian(bytes.data());
if (length == 0 || length > kMaximumEncodedFrameBytes) {
FailStream(stream, "CultMesh QUIC frame length is invalid.");
return false;
}
const size_t framed_length = sizeof(uint32_t) + static_cast<size_t>(length);
if (bytes.size() < framed_length) return true;
std::vector<uint8_t> frame(length);
std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length);
{
std::lock_guard<std::mutex> lock(stream->owner->gate);
stream->owner->received.emplace_back(std::move(frame));
}
bytes.erase(bytes.begin(), bytes.begin() + static_cast<std::ptrdiff_t>(framed_length));
if (stream->kind == kLatestOnlyStream && !bytes.empty()) {
FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames.");
return false;
}
}
return true;
}
QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) {
auto* stream = static_cast<StreamContext*>(context);
switch (event->Type) {
case QUIC_STREAM_EVENT_RECEIVE:
for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) {
const auto& buffer = event->RECEIVE.Buffers[index];
stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length);
}
ConsumeFrames(stream);
break;
case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
if (!stream->buffered.empty())
FailStream(stream, "CultMesh QUIC stream ended with a truncated frame.");
break;
case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE:
if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress)
stream->owner->api->StreamClose(stream_handle);
delete stream;
break;
default:
break;
}
return QUIC_STATUS_SUCCESS;
}
bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) {
if (certificate == nullptr) return false;
const auto* encoded = reinterpret_cast<const QUIC_BUFFER*>(certificate);
if (encoded->Buffer == nullptr || encoded->Length == 0) return false;
BYTE digest[32]{};
DWORD digest_length = sizeof(digest);
if (!CryptHashCertificate2(
BCRYPT_SHA256_ALGORITHM,
0,
nullptr,
encoded->Buffer,
encoded->Length,
digest,
&digest_length) || digest_length != sizeof(digest))
return false;
return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0;
}
std::string Hex(uint64_t value) {
std::ostringstream text;
text << "0x" << std::hex << std::uppercase << value;
return text.str();
}
std::string LoadedMsQuicPath() {
const auto module = GetModuleHandleW(L"msquic.dll");
if (module == nullptr) return "unresolved";
std::array<wchar_t, 32768> path{};
const auto length = GetModuleFileNameW(module, path.data(), static_cast<DWORD>(path.size()));
if (length == 0 || length >= path.size()) return "unresolved";
const auto required = WideCharToMultiByte(
CP_UTF8, 0, path.data(), static_cast<int>(length), nullptr, 0, nullptr, nullptr);
if (required <= 0) return "unresolved";
std::string utf8(static_cast<size_t>(required), '\0');
WideCharToMultiByte(
CP_UTF8, 0, path.data(), static_cast<int>(length), utf8.data(), required, nullptr, nullptr);
return utf8;
}
QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) {
auto* client = static_cast<Client*>(context);
AppendTrace(client, "event=" + std::to_string(static_cast<int>(event->Type)));
switch (event->Type) {
case QUIC_CONNECTION_EVENT_CONNECTED:
client->state.store(ClientState::Connected);
break;
case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
auto* stream = new (std::nothrow) StreamContext(client);
if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY;
client->api->SetCallbackHandler(
event->PEER_STREAM_STARTED.Stream,
reinterpret_cast<void*>(StreamCallback),
stream);
break;
}
case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED:
{
AppendTrace(
client,
"certificate-status=" +
Hex(static_cast<uint64_t>(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus)));
const auto matches = CertificateMatchesPin(
client,
event->PEER_CERTIFICATE_RECEIVED.Certificate);
if (!matches) {
SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin.");
}
const auto completion = client->api->ConnectionCertificateValidationComplete(
connection,
matches ? TRUE : FALSE,
matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE);
if (QUIC_FAILED(completion)) {
SetFailure(
client,
"CultMesh QUIC certificate validation completion failed (status=" +
Hex(static_cast<uint64_t>(completion)) + ").");
return completion;
}
return QUIC_STATUS_PENDING;
}
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT:
if (!client->closing.load()) {
SetFailure(
client,
"CultMesh QUIC connection was shut down by the transport "
"(status=" + Hex(static_cast<uint64_t>(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) +
", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) +
", msquic=" + LoadedMsQuicPath() +
", events=" + TraceSnapshot(client) + ").");
}
break;
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER:
if (!client->closing.load())
SetFailure(client, "CultMesh QUIC connection was shut down by the provider.");
break;
case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE:
if (client->closing.load()) client->state.store(ClientState::Closed);
break;
default:
break;
}
return QUIC_STATUS_SUCCESS;
}
void CloseHandles(Client* client) {
if (client->connection != nullptr) {
client->api->ConnectionClose(client->connection);
client->connection = nullptr;
}
if (client->configuration != nullptr) {
client->api->ConfigurationClose(client->configuration);
client->configuration = nullptr;
}
if (client->registration != nullptr) {
client->api->RegistrationClose(client->registration);
client->registration = nullptr;
}
if (client->api != nullptr) {
MsQuicClose(client->api);
client->api = nullptr;
}
}
} // namespace
CULTMESH_API int32_t cultmesh_quic_open(
const char* host,
uint16_t port,
const char* certificate_sha256,
Client** result) {
if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1;
*result = nullptr;
auto client = std::make_unique<Client>();
if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2;
QUIC_STATUS status = MsQuicOpenVersion(
kApiVersion,
reinterpret_cast<const void**>(&client->api));
if (QUIC_FAILED(status)) return status;
const QUIC_REGISTRATION_CONFIG registration_config = {
"GameCult.Mesh.Quic.Native",
QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME
};
status = client->api->RegistrationOpen(&registration_config, &client->registration);
if (QUIC_FAILED(status)) {
CloseHandles(client.get());
return status;
}
QUIC_SETTINGS settings{};
settings.IdleTimeoutMs = 30000;
settings.IsSet.IdleTimeoutMs = TRUE;
settings.KeepAliveIntervalMs = 5000;
settings.IsSet.KeepAliveIntervalMs = TRUE;
settings.PeerUnidiStreamCount = 1024;
settings.IsSet.PeerUnidiStreamCount = TRUE;
const QUIC_BUFFER alpn = {
static_cast<uint32_t>(sizeof(kAlpn) - 1),
reinterpret_cast<uint8_t*>(const_cast<char*>(kAlpn))
};
status = client->api->ConfigurationOpen(
client->registration,
&alpn,
1,
&settings,
sizeof(settings),
nullptr,
&client->configuration);
if (QUIC_FAILED(status)) {
CloseHandles(client.get());
return status;
}
QUIC_CREDENTIAL_CONFIG credentials{};
credentials.Type = QUIC_CREDENTIAL_TYPE_NONE;
credentials.Flags = static_cast<QUIC_CREDENTIAL_FLAGS>(
QUIC_CREDENTIAL_FLAG_CLIENT |
QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED |
QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION |
QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES);
status = client->api->ConfigurationLoadCredential(client->configuration, &credentials);
if (QUIC_FAILED(status)) {
CloseHandles(client.get());
return status;
}
status = client->api->ConnectionOpen(
client->registration,
ConnectionCallback,
client.get(),
&client->connection);
if (QUIC_FAILED(status)) {
CloseHandles(client.get());
return status;
}
status = client->api->ConnectionStart(
client->connection,
client->configuration,
QUIC_ADDRESS_FAMILY_UNSPEC,
host,
port);
if (QUIC_FAILED(status)) {
CloseHandles(client.get());
return status;
}
*result = client.release();
return 0;
}
CULTMESH_API int32_t cultmesh_quic_state(Client* client) {
if (client == nullptr) return static_cast<int32_t>(ClientState::Failed);
return static_cast<int32_t>(client->state.load());
}
CULTMESH_API int32_t cultmesh_quic_poll(
Client* client,
uint8_t* destination,
int32_t destination_length,
int32_t* required_length) {
if (client == nullptr || required_length == nullptr || destination_length < 0) return -1;
std::lock_guard<std::mutex> lock(client->gate);
if (client->received.empty()) {
*required_length = 0;
return client->state.load() == ClientState::Failed ? -2 : 0;
}
const auto& frame = client->received.front();
if (frame.size() > static_cast<size_t>(INT32_MAX)) return -3;
*required_length = static_cast<int32_t>(frame.size());
if (destination == nullptr || destination_length < *required_length) return 2;
std::memcpy(destination, frame.data(), frame.size());
client->received.pop_front();
return 1;
}
CULTMESH_API int32_t cultmesh_quic_error(
Client* client,
char* destination,
int32_t destination_length) {
if (client == nullptr || destination == nullptr || destination_length <= 0) return -1;
std::lock_guard<std::mutex> lock(client->gate);
const size_t count = (std::min)(client->error.size(), static_cast<size_t>(destination_length - 1));
std::memcpy(destination, client->error.data(), count);
destination[count] = '\0';
return static_cast<int32_t>(count);
}
CULTMESH_API void cultmesh_quic_close(Client* client) {
if (client == nullptr) return;
client->closing.store(true);
if (client->connection != nullptr && client->api != nullptr)
client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode);
CloseHandles(client);
client->state.store(ClientState::Closed);
delete client;
}

900
package-lock.json generated Normal file
View file

@ -0,0 +1,900 @@
{
"name": "cultlib",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cultlib",
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
"packages/cultmesh-ts",
"packages/cultmesh-browser"
],
"devDependencies": {
"esbuild": "^0.28.1",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1",
"playwright-core": "^1.62.1"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
"integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@gamecult/cultcache-ts": {
"resolved": "packages/cultcache-ts",
"link": true
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true,
"license": "MIT"
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"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": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-to-typescript": {
"version": "15.0.4",
"resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
"integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.5.5",
"@types/json-schema": "^7.0.15",
"@types/lodash": "^4.17.7",
"is-glob": "^4.0.3",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"minimist": "^1.2.8",
"prettier": "^3.2.5",
"tinyglobby": "^0.2.9"
},
"bin": {
"json2ts": "dist/src/cli.js"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts",
"version": "0.14.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cultmesh-browser": {
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
},
"engines": {
"node": ">=18"
}
},
"packages/cultmesh-ts": {
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
"cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0",
"zod": "^3.25.76"
},
"engines": {
"node": ">=18"
}
},
"packages/cultnet-ts": {
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.0",
"zod": "^3.25.76"
},
"engines": {
"node": ">=18"
}
}
}
}

View file

@ -4,10 +4,19 @@
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
"packages/cultmesh-ts"
"packages/cultmesh-ts",
"packages/cultmesh-browser"
],
"scripts": {
"build:ts": "npm --workspaces run build",
"test:ts": "npm --workspaces run test"
"build:ts": "node scripts/run-typescript-workspaces.mjs build",
"test:ts": "node scripts/run-typescript-workspaces.mjs test",
"test:pack:ts": "node scripts/test-typescript-package-closure.mjs",
"test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
},
"devDependencies": {
"esbuild": "^0.28.1",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1",
"playwright-core": "^1.62.1"
}
}

View file

@ -1,12 +1,16 @@
# cultcache-py
# cultcache-py
`cultcache-py` is the Python runtime package for CultCache, CultNet, and
CultMesh. Callers work with registered domain documents while the cache owns
schema identity, routing, globals, name lookups, indexes, and backing-store
envelopes.
`cultcache-py` is the Python CultCache runtime. Callers work with registered
domain documents while the cache owns schema identity, routing, globals, name
lookups, indexes, and backing-store envelopes.
It is intentionally small. It is not an ORM, a database, or distributed consensus wearing rented authority.
It is the bottom of the Python CultLib stack and depends only on `msgpack`.
`cultnet-py` (transport and wire contracts) and `cultmesh-py` (nodes,
discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
`cultnet-rs` / `cultmesh-rs` and the npm packages.
## Current Shape
- document types are registered explicitly
@ -17,14 +21,14 @@ It is intentionally small. It is not an ORM, a database, or distributed consensu
- payloads decode only through the registered document definition for that type
- unknown persisted types fail closed
- global documents are singleton-style per type
- type-specific backing stores beat generic backing stores
- one home store per document type: a store added with `add_backing_store`
owns its types, otherwise the generic store is the home; a type claimed by a
second store, or a second generic store, raises `CultCacheError`; a write
touches exactly one store and nothing is mirrored; once any store is
attached, a write whose type has no home raises
- the JSONL store uses only the Python standard library for bootstrap consumers
- `define_database_entry_type(...)` emits Rust/C#-style slot-indexed
MessagePack array payloads for cross-runtime `DatabaseEntry` contracts
- `cultnet_py` exposes schema-v0 MessagePack helpers and 4-byte big-endian
frame helpers for raw document state, subscriptions, shard catch-up,
simulation observations, and witness artifact bundles
- `cultmesh_py` exposes a cache-backed local node surface for Python tools
## Example
@ -144,336 +148,28 @@ That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C#
`[Key(n)]` intent: schema evolution comes from stable field slots, not source
member order.
## CultNet And CultMesh
`cultnet_py` provides the Python schema-v0 wire helpers and an interop peer:
```python
from datetime import UTC, datetime
from cultnet_py import (
CultNetSecret,
CultNetServerSecurityOptions,
CultNetRawClient,
CultNetSimulationObservation,
CultNetWitnessArtifactBundle,
apply_raw_snapshot,
apply_shard_log_response,
compute_simulation_claim_hash,
database_subscribe,
decode_frame,
encode_frame,
hello,
parse_message,
shard_catalog_request,
simulation_observation,
witness_artifact_bundle,
)
from cultmesh_py import CultMesh
payload = hello(runtime_id="python-runtime").to_bytes()
message = parse_message(decode_frame(encode_frame(payload)))
client = CultNetRawClient("127.0.0.1", 3075)
catalog = client.fetch_schema_catalog(kinds=["wire_message"])
snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"])
shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"])
# With a local CultCache and the matching registered document definitions:
# applied = apply_raw_snapshot(cache, [note_doc], snapshot)
subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"])
shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"])
shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0)
# log_changes = apply_shard_log_response(cache, [note_doc], shard_log)
with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live:
initial_snapshot = live.read_next_snapshot_response()
# The same raw-client request surface can ride RUDP when the peer advertises a
# rudp:// endpoint and shares the connection id for that lane.
rudp_client = CultMesh.create_client(
endpoint="rudp://127.0.0.1:3076",
connection_id=0x43554C54,
)
rudp_catalog = rudp_client.fetch_schema_catalog(kinds=["wire_message"])
claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit")
observation = simulation_observation(
message_id="obs-1",
witness_runtime_id="python-runtime",
shard_id="interop",
shard_epoch=1,
frame=42,
subject_id="player-1",
claim_kind="hit",
claim_hash=claim_hash,
)
typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire())
witness = witness_artifact_bundle(
bundle_id="bundle-1",
witness_kind="interop-proof",
captured_at="2026-06-13T00:00:00Z",
subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"},
contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}],
artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}],
provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"},
)
typed_witness = CultNetWitnessArtifactBundle.from_wire(witness)
witness_payload = typed_witness.to_payload()
security = CultNetServerSecurityOptions.development()
token = CultNetSecret.create_session_token(
"318fb4b6-ff5e-4c4f-b911-d81807de53a8",
datetime(2035, 1, 1, tzinfo=UTC),
security,
session_version=1,
)
session = CultNetSecret.try_validate_session_token(token, security)
```
AES-GCM string/byte encryption helpers are available through the same
`CultNetSecret` surface when the optional `crypto` extra is installed.
The peer can serve, dial, and probe the same raw-state interop lane used by the
TypeScript, Rust, and C# test peers:
```powershell
python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11
```
`cultmesh_py` includes a local cache-backed node, schema-v0 helpers for the
CultMesh Verse catalog and peer exchange wire messages, local and verified
authority lease checks, stream transport negotiation, committed simulation fact
documents, and local prediction reconciliation:
```python
from datetime import UTC, datetime, timedelta
from cultcache_py import define_database_entry_type
from cultnet_py import (
CultNetClientAuthorityScope,
CultNetFileShardReplicaCursorStore,
CultNetRawClient,
CultNetSchemaShardLogFetcher,
CultNetSchemaShardSnapshotFetcher,
CultNetShardDescriptor,
CultNetShardReplicator,
CultNetShardReplicatorOptions,
)
from cultmesh_py import (
CultMesh,
CultMeshAuthorityLease,
CultMeshGameSessionOptions,
CultMeshHmacAuthorityLeaseVerifier,
CultMeshNodeOptions,
peer_exchange_request,
)
note_doc = define_database_entry_type(
"mesh.note",
[("body", 0), ("name", 1), ("kind", 2)],
name="name",
indexes={"kind": "kind"},
)
node = CultMesh.start_node(
"mesh.cc",
runtime_id="python-runtime",
options=CultMeshNodeOptions(enable_durable_shard_logs=True),
)
node.database.register_document(note_doc)
unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind))
unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key))
unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key))
node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"})
live_note = node.database.get_required(note_doc, "note:1")
put_message = node.database.put_raw_message(
note_doc,
"note:2",
{"body": "wire me", "name": "wire", "kind": "interop"},
shard_id="interop",
shard_epoch=1,
)
snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1)
shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1)
shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1)
peers = CultMesh.create_peer_catalog()
response = peers.create_response(peer_exchange_request("pex-1", verse_id="local"))
lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"})
lease_catalog = CultMesh.create_authority_lease_catalog(
signature_verifier=lease_verifier.verify,
require_verified_signatures=True,
)
lease_valid_from = datetime.now(UTC)
lease = lease_verifier.issue(CultMeshAuthorityLease(
lease_id="lease:python-runtime",
verse_id="local",
peer_id="python-runtime",
roles=("shard-primary",),
valid_from=lease_valid_from,
expires_at=lease_valid_from + timedelta(minutes=5),
shard_ids=("interop",),
issuer_runtime_id="odin",
))
lease_catalog.upsert(lease)
session = CultMesh.create_game_session(
node,
CultMeshGameSessionOptions(
client_authority_scopes=(
CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"),
),
),
)
server = CultMesh.serve_node(
node,
peer_catalog=peers,
observation_hub=session.observation_hub,
port=3075,
max_snapshot_documents=1000,
max_snapshot_bytes=4 * 1024 * 1024,
)
```
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:
```powershell
python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json
```
The ready file and stdout line are process readiness hints. The served state and
capability truth still live on the framed CultNet/CultMesh endpoint. Use
`--register-interop-note` to expose the package interop-note schema without
seeding a note, or `--seed-interop-note` to register it and publish one local
record for wire probes. Seeded records are committed through the raw mutation
path, so peers can read them through both snapshot and shard-log catch-up. Add
`--verse-id` and one or more `--role` values to publish the launched endpoint in
its own Verse and peer catalogs for discovery clients. When the daemon is
launched with `--cache-file`, `--enable-durable-shard-logs`, and
`--shard-log-file`, a later daemon process can rehydrate and serve the persisted
snapshot and shard log without reseeding.
`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0`
and preserves the runtime id, display name, document types, message versions,
and mutation contracts reported by the peer.
```python
client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
verses = client.fetch_verses(transport_version="cultmesh.v0")
mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"])
client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
raw_client = CultNetRawClient("127.0.0.1", 3075)
node.database.sync_snapshot(raw_client, schema_ids=[note_doc.catalog_entry().schema_id])
node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1)
replicator = CultNetShardReplicator(
node.database,
CultNetShardReplicatorOptions(
fetcher=CultNetSchemaShardLogFetcher(),
snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(),
cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"),
poll_interval_seconds=1.0,
on_error=lambda error: print(error),
),
)
replica_shard = CultNetShardDescriptor(
shard_id="interop",
owner_runtime_id="primary-runtime",
epoch=1,
schema_ids=(note_doc.catalog_entry().schema_id,),
primary_endpoints=("cultnet://127.0.0.1:3075",),
)
replicator.pull_once(replica_shard)
replicator.start([replica_shard])
replicator.stop()
server.stop()
streams = CultMesh.create_stream_catalog()
claim_hash = "accepted-claim-hash"
facts = CultMesh.create_simulation_fact_committer(node)
committed = facts.commit({
"shardId": "arena",
"shardEpoch": 4,
"frame": 100,
"subjectId": "bob",
"claimKind": "hit",
"claimHash": claim_hash,
"witnessCount": 2,
"supportWeight": 2.0,
"totalWeight": 2.0,
"confidence": 1.0,
"hasQuorum": True,
})
session_commits = session.submit_and_commit(typed_observation)
prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"})
unsubscribe()
```
## Wire Parity
See [PARITY.md](PARITY.md) for the current evidence map, gates, and things this
package still does not claim.
See [docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for
the evidence map across the three Python packages.
The Python interop peer is `cultcache_py.interop`:
The store interop peer is `cultcache_py.interop`:
```powershell
python -m cultcache_py.interop write --file cache.cc --runtime-id python
python -m cultcache_py.interop read --file cache.cc
```
Current receipts:
`packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
CultCache v1 parity matrix with TypeScript, Rust, and C#. `cultcache_py` ships
a `py.typed` marker so downstream type checkers can inspect the package surface.
- `packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
CultCache v1 parity matrix with TypeScript, Rust, and C#.
- `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` includes Python in
the live TS/Rust/C#/Python schema-v0 peer ring: discovery, hello, schema
catalog, wire-message catalog discovery, raw snapshot, raw document
put/delete, mutation receipt, fire-command receipt, database subscription
changes, shard catalog, shard log catch-up, simulation consensus candidates,
committed simulation fact snapshots, Python session prediction reconciliation,
and witness artifact bundle round-trips. The same test asks the Python peer
for CultMesh Verse catalog and peer exchange responses over the CultNet pipe,
then verifies the public `CultMeshDiscoveryClient` can fetch typed Python
Verse and peer descriptors from that live endpoint. It also verifies
`CultNetRawClient` can fetch the live Python peer's schema catalog, raw
snapshot, and shard catalog.
- `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh
Verse catalog, peer exchange, authority lease, stream negotiation, CultNet
helper shapes, raw snapshot/shard-log application, simulation claim hashing,
consensus aggregation, game-session fact commits, local prediction
reconciliation, committed simulation fact payload slots, the local CultMesh
server's TCP and RUDP schema request paths, and witness artifact bundle
payload slots.
- `cultcache_py`, `cultnet_py`, and `cultmesh_py` ship `py.typed` markers so
downstream type checkers can inspect the package surface instead of treating
the runtime as an untyped xenos swamp.
## Performance Baseline
The package ships a lightweight benchmark for Python-owned hot paths. In a
CultLib source checkout, it can also run the C# reference benchmark beside it:
## Tests
```powershell
python -m cultcache_py.benchmark --records 1000 --json
python -m cultcache_py.compare_csharp --records 1000 --json
python -m cultcache_py.verify --json
$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
python -m unittest discover -s packages\cultcache-py\tests
```
It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed
CultNet MessagePack parse throughput, raw snapshot application into a
registered cache, and public `CultCache` upsert/get throughput. The C# compare
command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and
reports median Python-to-C# ratios for the shared public cache operations. It
uses three samples by default; pass `--samples` when you need a different
evidence shape. Both runtimes precompute record keys before measured cache
upsert/get loops so the comparison is about public cache access, not string
construction noise. The public in-memory cache owner stores records in
type-partitioned dictionaries so hot `get(...)` calls avoid per-call
composite-key allocation. Treat the benchmark numbers as a local baseline, not
a claim that Python matches the C# reference in every workload.
The suite runs with only this package on the path. If a test here needs
`cultnet_py` or `cultmesh_py`, it belongs in that package.

View file

@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "cultcache-py"
version = "0.1.0"
description = "Python CultCache, CultNet, and CultMesh runtime package for GameCult typed state."
version = "0.3.0"
description = "Python CultCache runtime for GameCult typed state."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Private GameCult infrastructure" }
license = "MIT"
authors = [{ name = "GameCult" }]
classifiers = [
"Programming Language :: Python :: 3",
@ -17,23 +17,11 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = ["msgpack>=1.0"]
[project.optional-dependencies]
msgpack = ["msgpack>=1.0"]
crypto = ["cryptography>=42"]
[project.scripts]
cultcache-py-interop = "cultcache_py.interop:main"
cultnet-py-interop = "cultnet_py.interop_peer:main"
cultcache-py-benchmark = "cultcache_py.benchmark:main"
cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main"
cultcache-py-verify = "cultcache_py.verify:main"
cultmesh-py-daemon = "cultmesh_py.daemon:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
cultcache_py = ["py.typed"]
cultnet_py = ["py.typed"]
cultmesh_py = ["py.typed"]

View file

@ -1,15 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Generic, TypeVar
import functools
import threading
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Generic, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
GLOBAL_KEY = "__global__"
def _locked(method: F) -> F:
"""Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation."""
@functools.wraps(method)
def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
with self._lock:
return method(self, *args, **kwargs)
return wrapper # type: ignore[return-value]
def _mutating(method: F) -> F:
"""Runs an attach, pull, registration, write or delete holding the cache's lock, so its validation,
store I/O and apply cannot interleave with another thread's. One started on a thread that is already
inside a mutation of this cache (from a store call, decoder, encoder, extractor or updater) raises
before touching anything."""
@functools.wraps(method)
def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
if self._mutating_thread == threading.get_ident():
raise CultCacheError(
f"CultCache refuses a re-entrant {method.__name__}: a mutation cannot start from inside a store call, "
"decoder, encoder, extractor or updater of another mutation on the same cache"
)
with self._lock:
self._mutating_thread = threading.get_ident()
try:
return method(self, *args, **kwargs)
finally:
self._mutating_thread = None
return wrapper # type: ignore[return-value]
class CultCacheError(RuntimeError):
pass
@ -17,14 +54,34 @@ class CultCacheError(RuntimeError):
@dataclass
class _State:
documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
values: dict[str, dict[str, Any]] = field(default_factory=dict)
envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict)
stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict)
generic_stores: list[BackingStore] = field(default_factory=list)
stores_by_type: dict[str, BackingStore] = field(default_factory=dict)
generic_store: BackingStore | None = None
name_extractors: dict[str, str | Any] = field(default_factory=dict)
index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict)
names: dict[tuple[str, str], str] = field(default_factory=dict)
indexes: dict[tuple[str, str, str], str] = field(default_factory=dict)
# The name and index values each held record produced when it was admitted. Removing a
# record reads these and runs no extractor, so removal has no fallible step.
lookup_keys: dict[str, dict[str, "_LookupKeys"]] = field(default_factory=dict)
# Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that
# type's global under GLOBAL_KEY, and its persisted key is kept here so the first write or delete
# of that global removes it from the home store. Remove once no store holds a global under a key
# other than GLOBAL_KEY.
legacy_global_keys: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
class _LookupKeys:
name: str | None
indexes: dict[str, str]
_Names = dict[tuple[str, str], str]
_Indexes = dict[tuple[str, str, str], str]
_LookupKeysByType = dict[str, dict[str, _LookupKeys]]
class CultCacheBuilder:
@ -68,54 +125,176 @@ class CultCache:
def __init__(self) -> None:
self._state = _State()
# Mutations hold this lock and refuse to start on the thread already inside one (see _mutating).
# It is reentrant so a mutation's extractors, decoders and updaters may still read the cache.
# Single-structure reads (get, get_all, get_envelope, key lookups) stay unlocked. The guarantee is
# per call: each reads one dict under the GIL and sees it wholly before or after a write's install.
# Two unlocked calls can straddle a write or pull, and see different states.
self._lock = threading.RLock()
self._mutating_thread: int | None = None
@_mutating
def register_document_type(self, document: DocumentDefinition[Any]) -> None:
self._register_document_type(document)
def _register_document_type(self, document: DocumentDefinition[Any]) -> None:
if document.type in self._state.documents:
raise CultCacheError(f"Document type already registered: {document.type}")
self._state.documents[document.type] = document
schema_name = document.catalog_entry().schema_name
if schema_name in self._state.documents_by_schema_name:
raise CultCacheError(f"Document schema name already registered: {schema_name}")
name_extractors = dict(self._state.name_extractors)
index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
if document.name is not None:
self.register_name_lookup(document, document.name)
for index, extractor in document.indexes.items():
self.register_index(document, index, extractor)
name_extractors[document.type] = document.name
if document.indexes:
index_extractors.setdefault(document.type, {}).update(document.indexes)
self._install_lookups(name_extractors, index_extractors)
self._state.documents[document.type] = document
self._state.documents_by_schema_name[schema_name] = document
@_mutating
def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None:
for document in documents:
self.register_document_type(document)
self._register_document_type(document)
@_mutating
def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None:
self._assert_registered(document)
self._state.name_extractors[document.type] = extractor
self._rebuild_indexes()
name_extractors = dict(self._state.name_extractors)
name_extractors[document.type] = extractor
self._install_lookups(name_extractors, self._state.index_extractors)
@_mutating
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
self._assert_registered(document)
self._state.index_extractors.setdefault(document.type, {})[index] = extractor
self._rebuild_indexes()
index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
index_extractors.setdefault(document.type, {})[index] = extractor
self._install_lookups(self._state.name_extractors, index_extractors)
def _install_lookups(
self, name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]]
) -> None:
"""Derives every lookup under the candidate extractors, then installs extractors and lookups
together. An extractor that raises on a held value installs nothing."""
names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors)
(
self._state.name_extractors,
self._state.index_extractors,
self._state.names,
self._state.indexes,
self._state.lookup_keys,
) = (name_extractors, index_extractors, names, indexes, lookup_keys)
@_mutating
def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None:
"""Makes the store home to the given types, or the generic store when there are none.
A type has exactly one home store, and attaching cannot move the home of a record the cache holds.
"""
types = list(types)
if not types:
self._add_generic_store(store)
return
for type in types:
self._state.stores_by_type.setdefault(type, []).append(store)
if type in self._state.stores_by_type:
raise CultCacheError(
f"Document type {type} is already routed to another backing store; it cannot also route to this one"
)
routes = dict(self._state.stores_by_type)
routes.update({type: store for type in types})
self._refuse_home_moves(routes, self._state.generic_store)
self._state.stores_by_type = routes
@_mutating
def add_generic_store(self, store: BackingStore) -> None:
self._state.generic_stores.append(store)
"""Makes the store home to every type no other store claims. A cache has at most one."""
self._add_generic_store(store)
def _add_generic_store(self, store: BackingStore) -> None:
if self._state.generic_store is not None:
raise CultCacheError(
"Backing store would be a second generic store; name the types it is home to"
)
self._refuse_home_moves(self._state.stores_by_type, store)
self._state.generic_store = store
def _refuse_home_moves(self, routes: dict[str, BackingStore], generic: BackingStore | None) -> None:
for type, values in self._state.values.items():
if not values:
continue
before = self._state.stores_by_type.get(type, self._state.generic_store)
after = routes.get(type, generic)
if before is not after:
raise CultCacheError(
f"Attaching this backing store would move {type} from {self._describe(before, routes, generic)} "
f"to {self._describe(after, routes, generic)}; attach routed stores before the generic store"
)
@staticmethod
def _describe(store: BackingStore | None, routes: dict[str, BackingStore], generic: BackingStore | None) -> str:
if store is None:
return "memory"
if store is generic:
return "the generic store"
return "the store routed to " + ", ".join(sorted(type for type, routed in routes.items() if routed is store))
@_mutating
def pull_all_backing_stores(self) -> None:
self._state.values.clear()
self._state.envelopes.clear()
"""Builds the complete next state (values, envelopes, name and index lookups) in local
structures, running every check and every user extractor; the cache's state is replaced
only once that build finishes, so a refused load admits nothing."""
loaded_values: dict[str, dict[str, Any]] = {}
loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {}
loaded_legacy_global_keys: dict[str, str] = {}
routes, generic = self._state.stores_by_type, self._state.generic_store
seen_globals: set[str] = set()
for store in [*self._all_specific_stores(), *self._state.generic_stores]:
stores: list[BackingStore] = []
for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]:
if candidate is not None and all(candidate is not store for store in stores):
stores.append(candidate)
for store in stores:
for envelope in store.pull_all():
document = self._state.documents.get(envelope.type)
document = self._resolve_document_for_envelope(envelope)
if document is None:
raise CultCacheError(f"Unknown persisted document type: {envelope.type}")
if envelope.type != document.type:
envelope = CultCacheEnvelope(
key=envelope.key,
type=document.type,
payload=envelope.payload,
stored_at=envelope.stored_at,
schema_id=envelope.schema_id,
catalog_entry=envelope.catalog_entry,
)
home = routes.get(envelope.type, generic)
if home is not store:
raise CultCacheError(
f"{envelope.type} record {envelope.key} was loaded from {self._describe(store, routes, generic)}, "
f"but its home is {self._describe(home, routes, generic)}"
)
if document.global_document:
if envelope.type in seen_globals and envelope.key == GLOBAL_KEY:
if envelope.type in seen_globals:
raise CultCacheError(f"Duplicate global document for type: {envelope.type}")
seen_globals.add(envelope.type)
if envelope.key != GLOBAL_KEY:
loaded_legacy_global_keys[envelope.type] = envelope.key
envelope = replace(envelope, key=GLOBAL_KEY)
value = document.decode_payload(envelope.payload)
self._state.values.setdefault(envelope.type, {})[envelope.key] = value
self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
self._rebuild_indexes()
loaded_values.setdefault(envelope.type, {})[envelope.key] = value
loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
loaded_values, self._state.name_extractors, self._state.index_extractors
)
# Plain attribute assignments: nothing fallible runs once the swap begins.
(
self._state.values,
self._state.envelopes,
self._state.names,
self._state.indexes,
self._state.lookup_keys,
self._state.legacy_global_keys,
) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys)
def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document)
@ -155,6 +334,9 @@ class CultCache:
self._assert_registered(document)
return self._state.names.get((document.type, name))
# Name and index resolution read the lookup and then the values, two structures a pull swaps and a
# write updates in sequence, so they hold the lock to resolve a key and its value from one state.
@_locked
def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None:
key = self.get_key_by_name(document, name)
return None if key is None else self.get(document, key)
@ -163,17 +345,19 @@ class CultCache:
self._assert_registered(document)
return self._state.indexes.get((document.type, index, value))
@_locked
def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None:
key = self.get_key_by_index(document, index, value)
return None if key is None else self.get(document, key)
@_mutating
def put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
self._put(document, key, value)
def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
self._assert_registered(document)
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
values = self._state.values.setdefault(document.type, {})
envelopes = self._state.envelopes.setdefault(document.type, {})
old_value = values.get(key)
catalog_entry = document.catalog_entry()
envelope = CultCacheEnvelope.create(
key=key,
@ -182,100 +366,139 @@ class CultCache:
schema_id=catalog_entry.schema_id,
catalog_entry=catalog_entry,
)
stores = self._stores_for_type(document.type)
for store in stores:
store.push(envelope)
if old_value is not None:
self._remove_value_indexes(document.type, key, old_value)
values[key] = value
envelopes[key] = envelope
self._add_value_indexes(document.type, key, value)
self._write(document, [(envelope, value)], batch=False)
@_mutating
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document)
if envelope.type != document.type:
raise CultCacheError(
f"Envelope type {envelope.type} does not match document type {document.type}"
)
values = self._state.values.setdefault(document.type, {})
envelopes = self._state.envelopes.setdefault(document.type, {})
old_value = values.get(envelope.key)
self._check_envelope(document, envelope)
value = document.decode_payload(envelope.payload)
for store in self._stores_for_type(document.type):
store.push(envelope)
if old_value is not None:
self._remove_value_indexes(document.type, envelope.key, old_value)
values[envelope.key] = value
envelopes[envelope.key] = envelope
self._add_value_indexes(document.type, envelope.key, value)
self._write(document, [(envelope, value)], batch=False)
return value
@_mutating
def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]:
self._assert_registered(document)
values: list[T] = []
for envelope in envelopes:
if envelope.type != document.type:
raise CultCacheError(
f"Envelope type {envelope.type} does not match document type {document.type}"
)
if document.global_document and envelope.key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
self._check_envelope(document, envelope)
values.append(document.decode_payload(envelope.payload))
stores = self._stores_for_type(document.type)
for store in stores:
store.push_all(envelopes)
values_by_key = self._state.values.setdefault(document.type, {})
envelopes_by_key = self._state.envelopes.setdefault(document.type, {})
for envelope, value in zip(envelopes, values):
old_value = values_by_key.get(envelope.key)
if old_value is not None:
self._remove_value_indexes(document.type, envelope.key, old_value)
values_by_key[envelope.key] = value
envelopes_by_key[envelope.key] = envelope
self._add_value_indexes(document.type, envelope.key, value)
self._write(document, list(zip(envelopes, values)), batch=True)
return values
@staticmethod
def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None:
if envelope.type != document.type:
raise CultCacheError(
f"Envelope type {envelope.type} does not match document type {document.type}"
)
if not envelope.key or not envelope.key.strip():
raise CultCacheError(f"Envelope key for document type {document.type} must be non-empty")
if document.global_document and envelope.key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
def _write(
self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool
) -> None:
"""Validates every record (home and name and index extractors included) before the store is
touched; once the store accepts, applying to the cache has no fallible step."""
type = document.type
name_extractor = self._state.name_extractors.get(type)
index_extractors = self._state.index_extractors.get(type, {})
admitted = [
(envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
for envelope, value in records
]
store = self._store_for_type(type)
legacy_key = self._state.legacy_global_keys.get(type)
if store is not None:
if batch:
store.push_all([envelope for envelope, _ in records])
else:
store.push(records[0][0])
if legacy_key is not None:
self._retire_legacy_global(store, type, legacy_key)
self._state.legacy_global_keys.pop(type, None)
values = self._state.values.setdefault(type, {})
envelopes = self._state.envelopes.setdefault(type, {})
lookup_keys = self._state.lookup_keys.setdefault(type, {})
for envelope, value, keys in admitted:
self._remove_lookups(type, envelope.key)
values[envelope.key] = value
envelopes[envelope.key] = envelope
lookup_keys[envelope.key] = keys
if keys.name is not None:
self._state.names[(type, keys.name)] = envelope.key
for index, index_value in keys.indexes.items():
self._state.indexes[(type, index, index_value)] = envelope.key
@staticmethod
def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
"""The first write of a global loaded from a legacy key removes that record in the same write. If
the removal fails, the new record is taken back out, so the store keeps only the legacy record."""
try:
store.delete(type, legacy_key)
except BaseException:
try:
store.delete(type, GLOBAL_KEY)
except Exception: # noqa: BLE001 - the removal failure is the error reported
pass
raise
@_mutating
def put_global(self, document: DocumentDefinition[T], value: T) -> None:
self._assert_global(document)
self.put(document, GLOBAL_KEY, value)
self._put(document, GLOBAL_KEY, value)
@_mutating
def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
return self._update(document, key, updater)
def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
current = self.get_required(document, key)
updated = updater(current)
self.put(document, key, updated)
self._put(document, key, updated)
return updated
@_mutating
def update_global(self, document: DocumentDefinition[T], updater: Any) -> T:
self._assert_global(document)
return self.update(document, GLOBAL_KEY, updater)
return self._update(document, GLOBAL_KEY, updater)
@_mutating
def delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
for store in self._stores_for_type(document.type):
store.delete(document.type, key)
values = self._state.values.get(document.type)
old_value = None if values is None else values.pop(key, None)
if values == {}:
self._state.values.pop(document.type, None)
envelopes = self._state.envelopes.get(document.type)
if envelopes is not None:
envelopes.pop(key, None)
if envelopes == {}:
self._state.envelopes.pop(document.type, None)
if old_value is not None:
self._remove_value_indexes(document.type, key, old_value)
self._delete(document, key)
def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
store = self._store_for_type(document.type)
legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
if store is not None:
store.delete(document.type, key if legacy_key is None else legacy_key)
if legacy_key is not None:
del self._state.legacy_global_keys[document.type]
self._remove_lookups(document.type, key)
for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
held = by_type.get(document.type)
if held is not None:
held.pop(key, None)
if not held:
by_type.pop(document.type, None)
@_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:
self._assert_global(document)
self.delete(document, GLOBAL_KEY)
self._delete(document, GLOBAL_KEY)
# Snapshots iterate dicts a concurrent write would resize mid-iteration, so they hold the lock.
@_locked
def snapshot(self) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for type, values in self._state.values.items():
out[type] = dict(values)
return out
@_locked
def snapshot_envelopes(self) -> list[CultCacheEnvelope]:
return [
envelope
@ -283,75 +506,70 @@ class CultCache:
for envelope in envelopes.values()
]
def _stores_for_type(self, type: str) -> list[BackingStore]:
specific = self._state.stores_by_type.get(type)
return specific if specific else self._state.generic_stores
def _store_for_type(self, type: str) -> BackingStore | None:
"""The store that claims the type, else the generic store.
def _all_specific_stores(self) -> list[BackingStore]:
stores: list[BackingStore] = []
seen: set[int] = set()
for routed in self._state.stores_by_type.values():
for store in routed:
marker = id(store)
if marker not in seen:
stores.append(store)
seen.add(marker)
return stores
Returns None only for a cache with no stores, which is an in-memory cache.
"""
store = self._state.stores_by_type.get(type, self._state.generic_store)
if store is None and self._state.stores_by_type:
raise CultCacheError(f"No backing store is home to document type: {type}")
return store
def _rebuild_indexes(self) -> None:
self._state.names.clear()
self._state.indexes.clear()
for type, values in self._state.values.items():
@staticmethod
def _lookup_keys_for(
value: Any, name_extractor: str | Any | None, index_extractors: dict[str, str | Any]
) -> _LookupKeys:
name = None if name_extractor is None else extract_value(value, name_extractor)
indexes: dict[str, str] = {}
for index, extractor in index_extractors.items():
index_value = extract_value(value, extractor)
if index_value is not None:
indexes[index] = str(index_value)
return _LookupKeys(None if name is None else str(name), indexes)
def _derive_lookups(
self,
values_by_type: dict[str, dict[str, Any]],
name_extractors: dict[str, str | Any],
index_extractors: dict[str, dict[str, str | Any]],
) -> tuple[_Names, _Indexes, _LookupKeysByType]:
"""Builds name and index lookups for the given values in fresh dicts, touching no cache state."""
names: _Names = {}
indexes: _Indexes = {}
lookup_keys: _LookupKeysByType = {}
for type, values in values_by_type.items():
name_extractor = name_extractors.get(type)
type_index_extractors = index_extractors.get(type, {})
for key, value in values.items():
self._add_value_indexes(type, key, value)
keys = self._lookup_keys_for(value, name_extractor, type_index_extractors)
lookup_keys.setdefault(type, {})[key] = keys
if keys.name is not None:
names[(type, keys.name)] = key
for index, index_value in keys.indexes.items():
indexes[(type, index, index_value)] = key
return names, indexes, lookup_keys
def _add_value_indexes(self, type: str, key: str, value: Any) -> None:
name_extractor = self._state.name_extractors.get(type)
if name_extractor is not None:
name = extract_value(value, name_extractor)
if name is not None:
self._state.names[(type, str(name))] = key
for index, extractor in self._state.index_extractors.get(type, {}).items():
index_value = extract_value(value, extractor)
if index_value is not None:
self._state.indexes[(type, index, str(index_value))] = key
def _remove_value_indexes(self, type: str, key: str, value: Any) -> None:
name_extractor = self._state.name_extractors.get(type)
if name_extractor is not None:
name = extract_value(value, name_extractor)
if name is not None:
self._remove_name_index(type, key, str(name))
for index, extractor in self._state.index_extractors.get(type, {}).items():
index_value = extract_value(value, extractor)
if index_value is not None:
self._remove_secondary_index(type, key, index, str(index_value))
def _remove_name_index(self, type: str, key: str, name: str) -> None:
lookup_key = (type, name)
if self._state.names.get(lookup_key) != key:
def _remove_lookups(self, type: str, key: str) -> None:
"""Removes the lookups the held record produced at admission, handing a name or index value to
another held record that produced it too. Reads stored lookup keys only; runs no extractor."""
held = self._state.lookup_keys.get(type, {})
keys = held.get(key)
if keys is None:
return
self._state.names.pop(lookup_key, None)
name_extractor = self._state.name_extractors.get(type)
if name_extractor is None:
return
for candidate_key, candidate in self._state.values.get(type, {}).items():
candidate_name = extract_value(candidate, name_extractor)
if candidate_key != key and candidate_name is not None and str(candidate_name) == name:
self._state.names[lookup_key] = candidate_key
def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None:
lookup_key = (type, index, value)
if self._state.indexes.get(lookup_key) != key:
return
self._state.indexes.pop(lookup_key, None)
extractor = self._state.index_extractors.get(type, {}).get(index)
if extractor is None:
return
for candidate_key, candidate in self._state.values.get(type, {}).items():
candidate_value = extract_value(candidate, extractor)
if candidate_key != key and candidate_value is not None and str(candidate_value) == value:
self._state.indexes[lookup_key] = candidate_key
if keys.name is not None and self._state.names.get((type, keys.name)) == key:
del self._state.names[(type, keys.name)]
for candidate_key, candidate in held.items():
if candidate_key != key and candidate.name == keys.name:
self._state.names[(type, keys.name)] = candidate_key
for index, index_value in keys.indexes.items():
lookup_key = (type, index, index_value)
if self._state.indexes.get(lookup_key) != key:
continue
del self._state.indexes[lookup_key]
for candidate_key, candidate in held.items():
if candidate_key != key and candidate.indexes.get(index) == index_value:
self._state.indexes[lookup_key] = candidate_key
def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
if self._state.documents.get(document.type) is not document:
@ -361,3 +579,9 @@ class CultCache:
self._assert_registered(document)
if not document.global_document:
raise CultCacheError(f"Document type is not global: {document.type}")
def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None:
document = self._state.documents.get(envelope.type)
if document is not None:
return document
return self._state.documents_by_schema_name.get(envelope.type)

View file

@ -178,12 +178,25 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
raise ValueError("CultCache persisted records must declare storedAt")
catalog_entry = catalog_by_schema_id.get(schema_id)
if catalog_entry is None:
raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"')
schema_version = _infer_schema_version_from_payload(_normalize_payload(payload))
schema_name = _infer_schema_name(schema_version) if schema_version is not None else None
if schema_name is None:
raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"')
catalog_entry = CultCacheSchemaCatalogEntry(
schema_id=schema_id,
schema_name=schema_name,
schema_version=schema_version,
content_hash=schema_id,
canonical_schema_json="",
compatible_schema_ids=(),
members=(),
)
normalized_payload = _normalize_payload(payload)
envelopes.append(
CultCacheEnvelope(
key=key,
type=catalog_entry.schema_name,
payload=_normalize_payload(payload),
payload=normalized_payload,
stored_at=stored_at,
schema_id=schema_id,
catalog_entry=catalog_entry,
@ -316,3 +329,27 @@ def _normalize_payload(payload: Any) -> bytes:
if isinstance(payload, list) and all(isinstance(item, int) and 0 <= item <= 255 for item in payload):
return bytes(payload)
raise ValueError("CultCache record payload must be binary MessagePack bytes")
def _infer_schema_version_from_payload(payload: bytes) -> str | None:
try:
msgpack = SingleFileMessagePackBackingStore._msgpack()
decoded = msgpack.unpackb(payload, raw=False)
except Exception:
return None
if isinstance(decoded, list) and decoded and isinstance(decoded[0], str):
return decoded[0]
if isinstance(decoded, dict):
value = decoded.get("schemaVersion", decoded.get("schema_version"))
return value if isinstance(value, str) else None
return None
def _infer_schema_name(schema_version: str) -> str | None:
marker = schema_version.rfind(".v")
if marker <= 0:
return None
version = schema_version[marker + 2:]
if not version or not version.isdigit():
return None
return schema_version[:marker]

View file

@ -1,135 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
from typing import Any
from cultcache_py import define_database_entry_type
from cultcache_py.documents import DocumentDefinition
from .node import CultMeshNode
SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact"
SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1"
@dataclass(frozen=True)
class CultMeshSimulationFact:
fact_id: str
shard_id: str
shard_epoch: int
frame: int
subject_id: str
claim_kind: str
claim_hash: str
claim_summary: str | None
witness_count: int
support_weight: float
total_weight: float
confidence: float
committed_at: str
@staticmethod
def compute_fact_id(candidate: dict[str, Any]) -> str:
canonical = "\x1f".join([
_candidate_string(candidate, "shardId", "shard_id"),
str(_candidate_int(candidate, "shardEpoch", "shard_epoch")),
str(_candidate_int(candidate, "frame", "frame")),
_candidate_string(candidate, "subjectId", "subject_id"),
_candidate_string(candidate, "claimKind", "claim_kind"),
])
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@staticmethod
def create_record_key(candidate: dict[str, Any]) -> str:
return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}"
@staticmethod
def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact":
return CultMeshSimulationFact(
fact_id=CultMeshSimulationFact.compute_fact_id(candidate),
shard_id=_candidate_string(candidate, "shardId", "shard_id"),
shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"),
frame=_candidate_int(candidate, "frame", "frame"),
subject_id=_candidate_string(candidate, "subjectId", "subject_id"),
claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"),
claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"),
claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"),
witness_count=_candidate_int(candidate, "witnessCount", "witness_count"),
support_weight=_candidate_float(candidate, "supportWeight", "support_weight"),
total_weight=_candidate_float(candidate, "totalWeight", "total_weight"),
confidence=_candidate_float(candidate, "confidence", "confidence"),
committed_at=committed_at or datetime.now(UTC).isoformat(),
)
simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type(
SIMULATION_FACT_DOCUMENT_TYPE,
[
("fact_id", 0),
("shard_id", 1),
("shard_epoch", 2),
("frame", 3),
("subject_id", 4),
("claim_kind", 5),
("claim_hash", 6),
("claim_summary", 7, None),
("witness_count", 8),
("support_weight", 9),
("total_weight", 10),
("confidence", 11),
("committed_at", 12),
],
cls=CultMeshSimulationFact,
name="fact_id",
indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
schema_version=SIMULATION_FACT_SCHEMA_VERSION,
)
@dataclass(frozen=True)
class CultMeshSimulationFactCommit:
key: str
fact: CultMeshSimulationFact
@dataclass
class CultMeshSimulationFactCommitter:
node: CultMeshNode
document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document
def __post_init__(self) -> None:
if all(document.type != self.document.type for document in self.node.documents):
self.node.register_document(self.document)
def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit:
if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")):
raise ValueError("Simulation candidate cannot be committed before quorum")
key = CultMeshSimulationFact.create_record_key(candidate)
fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at)
self.node.put(self.document, key, fact)
return CultMeshSimulationFactCommit(key=key, fact=fact)
def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str:
value = candidate.get(camel, candidate.get(snake, ""))
if value is None:
return ""
return str(value)
def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None:
value = candidate.get(camel, candidate.get(snake))
if value is None:
return None
return str(value)
def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int:
return int(candidate.get(camel, candidate.get(snake, 0)))
def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float:
return float(candidate.get(camel, candidate.get(snake, 0.0)))

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
[package]
name = "cultcache-rs"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT"
publish = false
[workspace]
@ -17,6 +18,7 @@ chrono = { version = "0.4", features = ["serde"] }
cultcache-rs-derive = { path = "cultcache-rs-derive" }
fs2 = "0.4"
rmp-serde = "1.3"
redb = "4.1.0"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
@ -25,3 +27,6 @@ uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
pretty_assertions = "1.4"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }

View file

@ -53,7 +53,7 @@ struct Settings {
let mut cache = CultCache::new();
cache.register_entry_type::<Settings>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
cache.pull_all_backing_stores()?;
cache.put("app", &Settings {
@ -136,7 +136,7 @@ pub struct PlayerData {
let mut cache = CultCache::builder()
.with_generated_entries(game_entries())
.with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack"))
.with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc"))
.build()?;
cache.pull_all_backing_stores()?;
@ -173,39 +173,71 @@ without pretending Rust has runtime reflection hiding under the floorboards.
- `get_envelope::<T>`
- `get_required_envelope::<T>`
- `get_all::<T>`
- `get_all_with_keys::<T>`
- `soa::<T>`
- `put::<T>`
- `put_envelope::<T>`
- `update::<T>`
- `delete::<T>`
- `snapshot`
- `snapshot_soa`
- `load_soa`
- `SingleFileMessagePackBackingStore`
## Structure Of Arrays
The C# CultCache hot path exposes CPU-local SoA scans as:
```csharp
Span<float> x = cache.Soa<PlayerTransform>()
.Column<float>(nameof(PlayerTransform.PositionX))
.Span;
```
Rust cannot discover those columns through runtime reflection, so each domain
type declares its column extractors explicitly with `SoaDocument` or the
`cultcache_soa!` helper:
```rust
cultcache_soa!(PlayerTransform {
"position_x" => |row: &PlayerTransform| row.position_x,
"health" => |row: &PlayerTransform| row.health,
});
let table = cache.soa::<PlayerTransform>()?;
let health = table.column::<i32>("health")?;
```
This is the same public shape as the C# rite: callers ask the cache for a typed
table, then pull contiguous typed columns by name. The Rust version is explicit
about the extractor table because the closed-world schema boundary is a virtue,
not a missing reflection party trick.
## Backing Store Routing
Generic store:
```rust
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?;
```
Type-specific store:
```rust
cache.add_backing_store(
SingleFileMessagePackBackingStore::new("players.msgpack"),
SingleFileMessagePackBackingStore::new("players.cc"),
["player"],
);
)?;
```
When writing a `PlayerData`, the cache checks type-specific stores first. If none
match, it writes to the first generic store. Later matching stores are mirrors.
The contract, shared with every runtime, is one home store per document type
(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
This mirrors the C# behavior:
- specific domain stores own their domain
- the first generic store is the primary generic write target
- later generic stores mirror writes
- this is not multi-master
- a type-specific store owns its types; otherwise the generic store is the home
- a second generic store is an error, and so is a type claimed by a second
store; both registrations return `Err` and attach nothing
- a write touches exactly one store; there are no mirrors
- a write whose type has no home store is an error
## Persistence Semantics

View file

@ -75,6 +75,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
};
let mut key: Option<usize> = None;
let mut default = false;
let mut bytes = false;
for attribute in field
.attrs
.iter()
@ -88,6 +89,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
} else if meta.path.is_ident("default") {
default = true;
Ok(())
} else if meta.path.is_ident("bytes") {
bytes = true;
Ok(())
} else {
Err(meta.error("unsupported field cultcache attribute"))
}
@ -117,6 +121,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
ident: field_ident,
ty: field.ty,
default,
bytes,
});
}
slots.sort_by_key(|slot| slot.key);
@ -125,8 +130,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let serialize_elements = (0..=max_slot).map(|slot| {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let field_ident = &field.ident;
quote! {
tuple.serialize_element(&self.#field_ident)?;
if field.bytes {
quote! {
tuple.serialize_element(::serde_bytes::Bytes::new(&self.#field_ident))?;
}
} else {
quote! {
tuple.serialize_element(&self.#field_ident)?;
}
}
} else {
quote! {
@ -138,8 +149,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let option_declarations = slots.iter().map(|field| {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
quote! {
let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
if field.bytes {
quote! {
let mut #variable: ::std::option::Option<::serde_bytes::ByteBuf> = ::std::option::Option::None;
}
} else {
quote! {
let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
}
}
});
@ -147,9 +164,17 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
quote! {
#slot => {
#variable = seq.next_element::<#ty>()?;
if field.bytes {
quote! {
#slot => {
#variable = seq.next_element::<::serde_bytes::ByteBuf>()?;
}
}
} else {
quote! {
#slot => {
#variable = seq.next_element::<#ty>()?;
}
}
}
} else {
@ -164,7 +189,16 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let build_fields = slots.iter().map(|field| {
let field_ident = &field.ident;
let variable = format_ident!("slot_{}", field.key);
if field.default {
if field.bytes && field.default {
quote! {
#field_ident: #variable.unwrap_or_default().into_vec()
}
} else if field.bytes {
let field_name = field_ident.to_string();
quote! {
#field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?.into_vec()
}
} else if field.default {
quote! {
#field_ident: #variable.unwrap_or_default()
}
@ -240,4 +274,5 @@ struct CultCacheSlot {
ident: syn::Ident,
ty: Type,
default: bool,
bytes: bool,
}

View file

@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> {
fn build_cache(file: &str) -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropNote>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
Ok(cache)
}

File diff suppressed because it is too large Load diff

View file

@ -53,7 +53,7 @@ import {
SingleFileMessagePackBackingStore,
defineDocumentRegistry,
defineDocumentType,
} from "cultcache-ts";
} from "@gamecult/cultcache-ts";
const itemDocument = defineDocumentType({
type: "item",
@ -142,6 +142,25 @@ Document definitions may also carry persistence metadata for cross-language
stores: `schemaId`, `schemaName`, `schemaVersion`, `contentHash`,
`canonicalSchemaJson`, `compatibleSchemaIds`, and slot `members`.
## Backing Store Routing
```ts
const cache = CultCache.builder()
.withRegistry(registry)
.withBackingStore(new SingleFileMessagePackBackingStore("settings.cc"), settingsDocument)
.withGenericStore(new SingleFileMessagePackBackingStore("cache.cc"))
.build();
```
A document type has exactly one home store
(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
- a store added with types owns those types; otherwise the generic store is the home
- a second generic store throws, and so does a type claimed by a second store;
a refused registration attaches nothing
- `put`, `putEnvelope` and `delete` touch exactly one store; nothing is mirrored
- a write whose type has no home store throws
## Name, Index, and Global Semantics
`name` and `indexes` can be declared on the document definition:
@ -161,11 +180,19 @@ const playerDocument = defineDocumentType({
You can also register them later if you feel like changing the machine after boot:
```ts
cache.registerNameLookup(playerDocument, "displayName");
cache.registerIndex(playerDocument, "faction", "faction");
await cache.registerNameLookup(playerDocument, "displayName");
await cache.registerIndex(playerDocument, "faction", "faction");
```
Global documents are singleton-style per type:
Registration, `addBackingStore`, `pullAllBackingStores` and every write and
delete run one at a time through a per-cache queue and return promises; reads
are synchronous. A write validates the whole record (home store, global key,
name and index accessors) before any store is touched, so a refused write
changes neither store nor cache. Registering an accessor that throws on a held
record installs nothing.
Global documents are singleton-style per type, always stored under
`CultCache.GLOBAL_KEY` (`__global__`); writing one under any other key throws:
```ts
await cache.putGlobal(settingsDocument, { theme: "ash", retries: 3 });

View file

@ -1,70 +0,0 @@
{
"name": "cultcache-ts",
"version": "0.13.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cultcache-ts",
"version": "0.13.5",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.0"
}
},
"node_modules/@msgpack/msgpack": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
"integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==",
"license": "ISC",
"engines": {
"node": ">= 18"
}
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "cultcache-ts",
"version": "0.13.5",
"name": "@gamecult/cultcache-ts",
"version": "0.14.0",
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@ -20,9 +20,10 @@
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --pretty false",
"test": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/cult-cache.test.js"
},
"keywords": [
"messagepack",
@ -34,6 +35,18 @@
],
"author": "metacratic",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/GameCult/CultLib.git",
"directory": "packages/cultcache-ts"
},
"bugs": {
"url": "https://github.com/GameCult/CultLib/issues"
},
"homepage": "https://github.com/GameCult/CultLib/tree/main/packages/cultcache-ts#readme",
"engines": {
"node": ">=18"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"zod": "^3.25.76"

View file

@ -74,13 +74,31 @@ function inspectV1Snapshot(filePath: string, fileSizeBytes: number, decoded: unk
catalogBySchemaId.set(compatibleSchemaId, entry);
}
}
const records = recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId));
for (const record of records) {
if (!catalog.some((entry) => entry.schemaId === record.schemaId)) {
const schemaVersion = inferSchemaVersionFromPayloadPreview(record.payloadPreview);
const schemaName = schemaVersion ? inferSchemaName(schemaVersion) : undefined;
if (schemaName) {
catalog.push({
schemaId: record.schemaId,
schemaName,
schemaVersion: schemaVersion!,
contentHash: record.schemaId,
canonicalSchemaJson: "",
compatibleSchemaIds: [],
members: [],
});
}
}
}
return {
filePath,
fileSizeBytes,
format: STORE_FORMAT_VERSION,
catalog,
records: recordsRaw.map((record) => decodeV1Record(record, catalogBySchemaId)),
records,
};
}
@ -184,8 +202,8 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, Inspected
throw new Error("CultCache persisted records must declare key, schemaId, and storedAt.");
}
const catalogEntry = catalogBySchemaId.get(schemaId);
const payloadBytes = normalizePayloadBytes(payload);
const catalogEntry = catalogBySchemaId.get(schemaId) ?? recoverCatalogEntry(schemaId, payloadBytes);
return {
key,
schemaId,
@ -196,6 +214,53 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map<string, Inspected
};
}
function recoverCatalogEntry(schemaId: string, payload: Uint8Array): InspectedCatalogEntry | undefined {
let payloadPreview: unknown;
try {
payloadPreview = decode(payload);
} catch {
return undefined;
}
const schemaVersion = inferSchemaVersionFromPayloadPreview(payloadPreview);
if (!schemaVersion) {
return undefined;
}
const schemaName = inferSchemaName(schemaVersion);
if (!schemaName) {
return undefined;
}
return {
schemaId,
schemaName,
schemaVersion,
contentHash: schemaId,
canonicalSchemaJson: "",
compatibleSchemaIds: [],
members: [],
};
}
function inferSchemaVersionFromPayloadPreview(payloadPreview: unknown): string | undefined {
if (Array.isArray(payloadPreview) && typeof payloadPreview[0] === "string") {
return payloadPreview[0];
}
if (isRecord(payloadPreview)) {
const schemaVersion = payloadPreview.schemaVersion ?? payloadPreview.schema_version;
return typeof schemaVersion === "string" ? schemaVersion : undefined;
}
return undefined;
}
function inferSchemaName(schemaVersion: string): string | undefined {
const match = /^(.*)\.v\d+$/.exec(schemaVersion);
return match?.[1];
}
function decodeLegacyEnvelope(value: unknown): InspectedRecord {
if (!isRecord(value)) {
throw new Error("CultCache legacy envelopes must be MessagePack maps.");

File diff suppressed because it is too large Load diff

View file

@ -27,3 +27,8 @@ export type {
InspectedCatalogMember,
InspectedRecord,
} from "./cult-cache-inspector";
export {
defineSwarmDocuments,
type DefineDocumentType,
type SwarmDocumentCatalog,
} from "./swarm-documents";

View file

@ -52,7 +52,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
const snapshot = decodeSnapshot(decoded);
if (snapshot) {
return snapshot.records.map((record) => {
const catalogEntry = snapshot.catalogBySchemaId.get(record.schemaId);
const catalogEntry = resolveCatalogEntryForRecord(record, snapshot.catalogBySchemaId);
if (!catalogEntry) {
throw new Error(`CultCache persisted record "${record.key}" references missing schema id "${record.schemaId}".`);
}
@ -166,7 +166,7 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
try {
await writeFile(tempPath, encode(encodeSnapshot(entries)));
await rename(tempPath, this.filePath);
await renameWithRetry(tempPath, this.filePath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
@ -174,6 +174,32 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
}
}
async function renameWithRetry(source: string, destination: string): Promise<void> {
let delayMs = 10;
let lastError: unknown;
for (let attempt = 0; attempt < 10; attempt++) {
try {
await rename(source, destination);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") {
throw error;
}
lastError = error;
await delay(delayMs);
delayMs *= 2;
}
}
throw lastError;
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
type PersistedRecord = {
key: string;
schemaId: string;
@ -282,6 +308,59 @@ function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined {
return { catalogBySchemaId, records };
}
function resolveCatalogEntryForRecord(
record: PersistedRecord,
catalogBySchemaId: Map<string, CultCacheSchemaCatalogEntry>,
): CultCacheSchemaCatalogEntry | undefined {
const existing = catalogBySchemaId.get(record.schemaId);
if (existing) {
return existing;
}
const schemaVersion = inferSchemaVersionFromPayload(record.payload);
if (!schemaVersion) {
return undefined;
}
const schemaName = inferSchemaName(schemaVersion);
if (!schemaName) {
return undefined;
}
return {
schemaId: record.schemaId,
schemaName,
schemaVersion,
contentHash: record.schemaId,
canonicalSchemaJson: "",
compatibleSchemaIds: [],
members: [],
};
}
function inferSchemaVersionFromPayload(payload: Uint8Array): string | undefined {
try {
const decoded = decode(payload);
if (Array.isArray(decoded) && typeof decoded[0] === "string") {
return decoded[0];
}
if (isObject(decoded)) {
const schemaVersion = decoded.schemaVersion ?? decoded.schema_version;
return typeof schemaVersion === "string" ? schemaVersion : undefined;
}
return undefined;
} catch {
return undefined;
}
}
function inferSchemaName(schemaVersion: string): string | undefined {
const match = /^(.*)\.v\d+$/.exec(schemaVersion);
return match?.[1];
}
function decodeCatalogEntry(value: unknown): CultCacheSchemaCatalogEntry {
if (!Array.isArray(value)) {
throw new Error("CultCache schema catalog entries must be MessagePack arrays.");

View file

@ -0,0 +1,561 @@
// GameCult swarm document-type catalog.
//
// These are CultCache document definitions for documents that cross service
// boundaries: Eve surfaces and provider advertisements, Ghostlight, Heimdall,
// Idunn, Muninn, Sleipnir, Aetheria, VoidBot, Stonks. The catalog lived in Odin
// because Odin was written first, not because Odin owns it; every service that
// decodes another service documents needs the same definitions, and a second
// copy would be a second definition of one wire format.
//
// defineDocumentType is injected rather than imported. That is how it worked in
// Odin, it keeps this file clear of the generic signature in ./document, and it
// lets a caller supply a runtime other than this package.
/** An undecoded document payload. These definitions predate typing and name fields defensively. */
export type DocumentValue = any;
export type DefineDocumentType = (definition: Record<string, unknown>) => unknown;
export type SwarmDocumentCatalog = Record<string, unknown>;
function parseObjectDocument(label: string) {
return {
parse(value: unknown) {
if (!value || typeof value !== "object") {
throw new Error(`${label} must be an object`);
}
return value;
},
};
}
export function defineSwarmDocuments(defineDocumentType?: DefineDocumentType | null): SwarmDocumentCatalog {
if (!defineDocumentType) {
return {
interfaceBindingDefinition: null,
evePluginAdvertisementDefinition: null,
ghostlightSchemaCatalogDefinition: null,
heimdallCommandBoundaryDefinition: null,
heimdallTransportProfileDefinition: null,
idunnCommandBoundaryDefinition: null,
idunnDaemonHealthDefinition: null,
idunnTransportProfileDefinition: null,
interfaceLayoutDefinition: null,
muninnCaptureStreamCommandDefinition: null,
muninnCaptureStreamDefinition: null,
muninnCommandBoundaryDefinition: null,
muninnMediaAudioPacketDefinition: null,
muninnMediaReceiverFeedbackDefinition: null,
muninnMediaVideoAccessUnitDefinition: null,
muninnHidControllerStateDefinition: null,
muninnMoveControllerStateDefinition: null,
muninnMoveIdentityDefinition: null,
muninnMoveLightCommandDefinition: null,
muninnMoveMarkerCandidateDefinition: null,
muninnObsStreamCatalogDefinition: null,
muninnQuestAccessDefinition: null,
muninnTelemetrySurfaceDefinition: null,
muninnTransportProfileDefinition: null,
SleipnirInputMappingDefinition: null,
aetheriaAssetManifestDefinition: null,
aetheriaGravityViewportDefinition: null,
aetheriaObjectsViewportDefinition: null,
aetheriaRenderSplatsViewportDefinition: null,
operatorStateDefinition: null,
providerAdvertisementDefinition: null,
voidbotProviderCatalogDefinition: null,
stonksCommandBoundaryDefinition: null,
stonksMarketSnapshotDefinition: null,
stonksRequestEventDefinition: null,
stonksTransportProfileDefinition: null,
streamPixelsCommandBoundaryDefinition: null,
streamPixelsTransportProfileDefinition: null,
surfaceDefinition: null,
viliCommandBoundaryDefinition: null,
viliTransportProfileDefinition: null,
weksaCommandBoundaryDefinition: null,
weksaOperatorStateDefinition: null,
weksaTransportProfileDefinition: null,
voidbotSwarmSnapshotDefinition: null,
};
}
const surfaceDefinition = defineDocumentType({
type: "gamecult.eve.surface_state",
schemaName: "gamecult.eve.surface_state",
schemaId: "gamecult.eve.surface_state.v1",
schemaVersion: "gamecult.eve.surface_state.v1",
global: false,
name: (value: DocumentValue) => value?.providerId || "surface",
schema: { parse: (value: DocumentValue) => value },
members: [
{ slot: 0, memberName: "providerId", typeName: "string", isName: true },
{ slot: 1, memberName: "title", typeName: "string" },
{ slot: 2, memberName: "version", typeName: "long" },
{ slot: 3, memberName: "updatedAt", typeName: "string" },
{ slot: 4, memberName: "surface", typeName: "object" },
],
});
const interfaceBindingDefinition = defineDocumentType({
type: "gamecult.eve.interface_binding",
schemaName: "gamecult.eve.interface_binding",
schemaId: "gamecult.eve.interface_binding.v1",
schemaVersion: "gamecult.eve.interface_binding.v1",
global: false,
name: (value: DocumentValue) => value?.bindingId || value?.providerId || "interface",
schema: parseObjectDocument("Eve interface binding"),
});
const providerAdvertisementDefinition = defineDocumentType({
type: "gamecult.eve.provider_advertisement",
schemaName: "gamecult.eve.provider_advertisement",
schemaId: "gamecult.eve.provider_advertisement.v1",
schemaVersion: "gamecult.eve.provider_advertisement.v1",
global: false,
name: (value: DocumentValue) => value?.providerId || value?.provider?.id || "provider",
schema: parseObjectDocument("Eve provider advertisement"),
});
const evePluginAdvertisementDefinition = defineDocumentType({
type: "gamecult.eve.plugin_advertisement",
schemaName: "gamecult.eve.plugin_advertisement",
schemaId: "gamecult.eve.plugin_advertisement.v1",
schemaVersion: "gamecult.eve.plugin_advertisement.v1",
global: false,
name: (value: DocumentValue) => value?.pluginId || value?.plugin?.id || "plugin",
schema: parseObjectDocument("Eve plugin advertisement"),
});
const ghostlightSchemaCatalogDefinition = defineDocumentType({
type: "ghostlight.schema_catalog",
schemaName: "ghostlight.schema_catalog",
schemaId: "ghostlight.schema_catalog.v1",
schemaVersion: "ghostlight.schema_catalog.v1",
global: false,
name: (value: DocumentValue) => value?.providerId || "gamecult.ghostlight.dungeon",
schema: parseObjectDocument("Ghostlight public schema catalog"),
});
const heimdallCommandBoundaryDefinition = defineDocumentType({
type: "heimdall.command_boundary",
schemaName: "heimdall.command_boundary",
schemaId: "heimdall.command_boundary.v1",
schemaVersion: "heimdall.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "heimdall",
schema: parseObjectDocument("Heimdall command boundary"),
});
const heimdallTransportProfileDefinition = defineDocumentType({
type: "heimdall.transport_profile",
schemaName: "heimdall.transport_profile",
schemaId: "heimdall.transport_profile.v1",
schemaVersion: "heimdall.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profileId || value?.daemonId || "heimdall",
schema: parseObjectDocument("Heimdall transport profile"),
});
const interfaceLayoutDefinition = defineDocumentType({
type: "odin.interface_layout",
schemaName: "odin.interface_layout",
schemaId: "odin.interface_layout.v1",
schemaVersion: "odin.interface_layout.v1",
global: false,
name: (value: DocumentValue) => value?.layoutId || value?.providerId || "odin.providers",
schema: parseObjectDocument("Odin interface layout"),
members: [
{ slot: 0, memberName: "layoutId", typeName: "string", isName: true },
{ slot: 1, memberName: "updatedAt", typeName: "string" },
{ slot: 2, memberName: "tiles", typeName: "object" },
],
});
const idunnDaemonHealthDefinition = defineDocumentType({
type: "idunn.daemon_health",
schemaName: "idunn.daemon_health",
schemaId: "idunn.daemon_health",
schemaVersion: "idunn.daemon_health.v1",
global: false,
name: (value: DocumentValue) => value?.daemonId || value?.daemon_id || "daemon",
schema: parseObjectDocument("Idunn daemon health"),
});
const idunnCommandBoundaryDefinition = defineDocumentType({
type: "idunn.command_boundary",
schemaName: "idunn.command_boundary",
schemaId: "idunn.command_boundary.v1",
schemaVersion: "idunn.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "daemon",
schema: parseObjectDocument("Idunn command boundary"),
});
const idunnTransportProfileDefinition = defineDocumentType({
type: "idunn.daemon_transport_profile",
schemaName: "idunn.daemon_transport_profile",
schemaId: "idunn.daemon_transport_profile.v1",
schemaVersion: "idunn.daemon_transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "daemon",
schema: parseObjectDocument("Idunn daemon transport profile"),
});
const muninnCaptureStreamDefinition = defineDocumentType({
type: "muninn.capture_stream",
schemaName: "muninn.capture_stream",
schemaId: "muninn.capture_stream.v1",
schemaVersion: "muninn.capture_stream.v1",
global: false,
name: (value: DocumentValue) => value?.stream_id || value?.streamId || "capture-stream",
schema: parseObjectDocument("Muninn capture stream"),
});
const muninnCaptureStreamCommandDefinition = defineDocumentType({
type: "muninn.capture_stream_command",
schemaName: "muninn.capture_stream_command",
schemaId: "muninn.capture_stream_command.v1",
schemaVersion: "muninn.capture_stream_command.v1",
global: false,
name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.stream_id || value?.streamId || "capture-stream-command",
schema: parseObjectDocument("Muninn capture stream command"),
});
const muninnCommandBoundaryDefinition = defineDocumentType({
type: "muninn.command_boundary",
schemaName: "muninn.command_boundary",
schemaId: "muninn.command_boundary.v1",
schemaVersion: "muninn.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "muninn",
schema: parseObjectDocument("Muninn command boundary"),
});
const muninnMediaVideoAccessUnitDefinition = defineDocumentType({
type: "muninn.media_video_access_unit",
schemaName: "muninn.media_video_access_unit",
schemaId: "muninn.media_video_access_unit.v1",
schemaVersion: "muninn.media_video_access_unit.v1",
global: false,
name: (value: DocumentValue) => {
const streamId = value?.stream_id || value?.streamId || "stream";
const sessionId = value?.session_id || value?.sessionId || "session";
const frameId = value?.frame_id ?? value?.frameId ?? "frame";
const chunkIndex = value?.chunk_index ?? value?.chunkIndex ?? "chunk";
return `${streamId}:${sessionId}:video:${frameId}:${chunkIndex}`;
},
schema: parseObjectDocument("Muninn media video access unit"),
});
const muninnMediaAudioPacketDefinition = defineDocumentType({
type: "muninn.media_audio_packet",
schemaName: "muninn.media_audio_packet",
schemaId: "muninn.media_audio_packet.v1",
schemaVersion: "muninn.media_audio_packet.v1",
global: false,
name: (value: DocumentValue) => {
const streamId = value?.stream_id || value?.streamId || "stream";
const sessionId = value?.session_id || value?.sessionId || "session";
const packetId = value?.packet_id ?? value?.packetId ?? "packet";
return `${streamId}:${sessionId}:audio:${packetId}`;
},
schema: parseObjectDocument("Muninn media audio packet"),
});
const muninnMediaReceiverFeedbackDefinition = defineDocumentType({
type: "muninn.media_receiver_feedback",
schemaName: "muninn.media_receiver_feedback",
schemaId: "muninn.media_receiver_feedback.v1",
schemaVersion: "muninn.media_receiver_feedback.v1",
global: false,
name: (value: DocumentValue) => {
const streamId = value?.stream_id || value?.streamId || "stream";
const sessionId = value?.session_id || value?.sessionId || "session";
const receiverId = value?.receiver_id || value?.receiverId || "receiver";
return `${streamId}:${sessionId}:feedback:${receiverId}`;
},
schema: parseObjectDocument("Muninn media receiver feedback"),
});
const muninnMoveControllerStateDefinition = defineDocumentType({
type: "muninn.move_controller_state",
schemaName: "muninn.move_controller_state",
schemaId: "muninn.move_controller_state.v1",
schemaVersion: "muninn.move_controller_state.v1",
global: false,
name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.move_id || value?.moveId || "move-controller-state",
schema: parseObjectDocument("Muninn Move controller state"),
});
const muninnHidControllerStateDefinition = defineDocumentType({
type: "muninn.hid_controller_state",
schemaName: "muninn.hid_controller_state",
schemaId: "muninn.hid_controller_state.v1",
schemaVersion: "muninn.hid_controller_state.v1",
global: false,
name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.device_id || value?.deviceId || "hid-controller-state",
schema: parseObjectDocument("Muninn HID controller state"),
});
const muninnMoveIdentityDefinition = defineDocumentType({
type: "muninn.move_identity",
schemaName: "muninn.move_identity",
schemaId: "muninn.move_identity.v1",
schemaVersion: "muninn.move_identity.v1",
global: false,
name: (value: DocumentValue) => value?.identity_id || value?.identityId || value?.move_id || value?.moveId || "move-identity",
schema: parseObjectDocument("Muninn Move identity"),
});
const muninnMoveLightCommandDefinition = defineDocumentType({
type: "muninn.move_light_command",
schemaName: "muninn.move_light_command",
schemaId: "muninn.move_light_command.v1",
schemaVersion: "muninn.move_light_command.v1",
global: false,
name: (value: DocumentValue) => value?.command_id || value?.commandId || value?.move_id || value?.moveId || "move-light-command",
schema: parseObjectDocument("Muninn Move light command"),
});
const muninnMoveMarkerCandidateDefinition = defineDocumentType({
type: "muninn.move_marker_candidate",
schemaName: "muninn.move_marker_candidate",
schemaId: "muninn.move_marker_candidate.v1",
schemaVersion: "muninn.move_marker_candidate.v1",
global: false,
name: (value: DocumentValue) => value?.stream_id || value?.streamId || value?.camera_id || value?.cameraId || "move-marker-candidate",
schema: parseObjectDocument("Muninn Move marker candidate"),
});
const muninnObsStreamCatalogDefinition = defineDocumentType({
type: "muninn.obs_stream_catalog",
schemaName: "muninn.obs_stream_catalog",
schemaId: "muninn.obs_stream_catalog.v1",
schemaVersion: "muninn.obs_stream_catalog.v1",
global: false,
name: (value: DocumentValue) => value?.catalog_id || value?.catalogId || value?.host_id || value?.hostId || "muninn",
schema: parseObjectDocument("Muninn OBS stream catalog"),
});
const muninnQuestAccessDefinition = defineDocumentType({
type: "muninn.quest_access",
schemaName: "muninn.quest_access",
schemaId: "muninn.quest_access.v1",
schemaVersion: "muninn.quest_access.v1",
global: false,
name: (value: DocumentValue) => value?.access_id || value?.accessId || value?.serial || "quest-access",
schema: parseObjectDocument("Muninn Quest access"),
});
const muninnTelemetrySurfaceDefinition = defineDocumentType({
type: "muninn.telemetry_surface",
schemaName: "muninn.telemetry_surface",
schemaId: "muninn.telemetry_surface.v1",
schemaVersion: "muninn.telemetry_surface.v1",
global: false,
name: (value: DocumentValue) => value?.surface_id || value?.surfaceId || value?.host_id || value?.hostId || "muninn-telemetry",
schema: parseObjectDocument("Muninn telemetry surface"),
});
const muninnTransportProfileDefinition = defineDocumentType({
type: "muninn.transport_profile",
schemaName: "muninn.transport_profile",
schemaId: "muninn.transport_profile.v1",
schemaVersion: "muninn.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "muninn",
schema: parseObjectDocument("Muninn transport profile"),
});
const SleipnirInputMappingDefinition = defineDocumentType({
type: "sleipnir.input_mapping",
schemaName: "sleipnir.input_mapping",
schemaId: "sleipnir.input_mapping.v1",
schemaVersion: "sleipnir.input_mapping.v1",
global: false,
name: (value: DocumentValue) => value?.providerId || value?.provider_id || "sleipnir",
schema: parseObjectDocument("Sleipnir input mapping"),
});
const aetheriaAssetManifestDefinition = defineDocumentType({
type: "gamecult.aetheria.asset_manifest",
schemaName: "gamecult.aetheria.asset_manifest",
schemaId: "gamecult.aetheria.asset_manifest.v1",
schemaVersion: "gamecult.aetheria.asset_manifest.v1",
global: false,
name: (value: DocumentValue) => value?.runId || value?.run_id || "aetheria-assets",
schema: parseObjectDocument("Aetheria asset manifest"),
});
const aetheriaGravityViewportDefinition = defineDocumentType({
type: "gamecult.fields.gravity",
schemaName: "gamecult.fields.gravity",
schemaId: "gamecult.fields.gravity.v1",
schemaVersion: "gamecult.fields.gravity.v1",
global: false,
name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-gravity",
schema: parseObjectDocument("Aetheria gravity viewport"),
});
const aetheriaObjectsViewportDefinition = defineDocumentType({
type: "gamecult.fields.objects",
schemaName: "gamecult.fields.objects",
schemaId: "gamecult.fields.objects.v1",
schemaVersion: "gamecult.fields.objects.v1",
global: false,
name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-objects",
schema: parseObjectDocument("Aetheria objects viewport"),
});
const aetheriaRenderSplatsViewportDefinition = defineDocumentType({
type: "gamecult.fields.splats",
schemaName: "gamecult.fields.splats",
schemaId: "gamecult.fields.splats.v1",
schemaVersion: "gamecult.fields.splats.v1",
global: false,
name: (value: DocumentValue) => value?.viewportId || value?.viewport_id || "aetheria-render-splats",
schema: parseObjectDocument("Aetheria render splats viewport"),
});
const operatorStateDefinition = defineDocumentType({
type: "gamecult.vili.operator_state",
schemaName: "gamecult.vili.operator_state",
schemaId: "gamecult.vili.operator_state.v1",
schemaVersion: "gamecult.vili.operator_state.v1",
global: false,
name: (value: DocumentValue) => value?.service || value?.providerId || "operator",
schema: parseObjectDocument("Vili operator state"),
});
const viliCommandBoundaryDefinition = defineDocumentType({
type: "gamecult.vili.command_boundary",
schemaName: "gamecult.vili.command_boundary",
schemaId: "gamecult.vili.command_boundary.v1",
schemaVersion: "gamecult.vili.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "vili",
schema: parseObjectDocument("Vili command boundary"),
});
const viliTransportProfileDefinition = defineDocumentType({
type: "gamecult.vili.transport_profile",
schemaName: "gamecult.vili.transport_profile",
schemaId: "gamecult.vili.transport_profile.v1",
schemaVersion: "gamecult.vili.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profileId || value?.daemonId || "vili",
schema: parseObjectDocument("Vili transport profile"),
});
const weksaOperatorStateDefinition = defineDocumentType({
type: "weksa.operator_state",
schemaName: "weksa.operator_state",
schemaId: "weksa.operator_state.v0",
schemaVersion: "weksa.operator_state.v0",
global: false,
name: (value: DocumentValue) => value?.daemon_id || value?.daemonId || value?.provider_id || value?.providerId || "weksa",
schema: parseObjectDocument("Weksa operator state"),
});
const weksaCommandBoundaryDefinition = defineDocumentType({
type: "weksa.command_boundary",
schemaName: "weksa.command_boundary",
schemaId: "weksa.command_boundary.v1",
schemaVersion: "weksa.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundary_id || value?.boundaryId || value?.daemon_id || value?.daemonId || "weksa",
schema: parseObjectDocument("Weksa command boundary"),
});
const weksaTransportProfileDefinition = defineDocumentType({
type: "weksa.transport_profile",
schemaName: "weksa.transport_profile",
schemaId: "weksa.transport_profile.v1",
schemaVersion: "weksa.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profile_id || value?.profileId || value?.daemon_id || value?.daemonId || "weksa",
schema: parseObjectDocument("Weksa transport profile"),
});
const stonksRequestEventDefinition = defineDocumentType({
type: "stonks.request_event",
schemaName: "stonks.request_event",
schemaId: "stonks.request_event.v1",
schemaVersion: "stonks.request_event.v1",
global: false,
name: (value: DocumentValue) => value?.id || "request",
schema: parseObjectDocument("Stonks request event"),
});
const stonksMarketSnapshotDefinition = defineDocumentType({
type: "stonks.market_snapshot",
schemaName: "stonks.market_snapshot",
schemaId: "stonks.market_snapshot.v1",
schemaVersion: "stonks.market_snapshot.v1",
global: true,
schema: parseObjectDocument("Stonks market snapshot"),
});
const stonksCommandBoundaryDefinition = defineDocumentType({
type: "stonks.command_boundary",
schemaName: "stonks.command_boundary",
schemaId: "stonks.command_boundary.v1",
schemaVersion: "stonks.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "stonks",
schema: parseObjectDocument("Stonks command boundary"),
});
const stonksTransportProfileDefinition = defineDocumentType({
type: "stonks.transport_profile",
schemaName: "stonks.transport_profile",
schemaId: "stonks.transport_profile.v1",
schemaVersion: "stonks.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profileId || value?.daemonId || "stonks",
schema: parseObjectDocument("Stonks transport profile"),
});
const streamPixelsCommandBoundaryDefinition = defineDocumentType({
type: "streampixels.command_boundary",
schemaName: "streampixels.command_boundary",
schemaId: "streampixels.command_boundary.v1",
schemaVersion: "streampixels.command_boundary.v1",
global: false,
name: (value: DocumentValue) => value?.boundaryId || value?.daemonId || "streampixels",
schema: parseObjectDocument("StreamPixels command boundary"),
});
const streamPixelsTransportProfileDefinition = defineDocumentType({
type: "streampixels.transport_profile",
schemaName: "streampixels.transport_profile",
schemaId: "streampixels.transport_profile.v1",
schemaVersion: "streampixels.transport_profile.v1",
global: false,
name: (value: DocumentValue) => value?.profileId || value?.daemonId || "streampixels",
schema: parseObjectDocument("StreamPixels transport profile"),
});
const voidbotSwarmSnapshotDefinition = defineDocumentType({
type: "voidbot.swarm_state_snapshot",
schemaName: "voidbot.swarm_state_snapshot",
schemaId: "voidbot.swarm_state_snapshot.v1",
schemaVersion: "voidbot.swarm_state_snapshot.v1",
global: true,
schema: parseObjectDocument("VoidBot swarm snapshot"),
});
const voidbotProviderCatalogDefinition = defineDocumentType({
type: "voidbot.provider_advertisement_catalog",
schemaName: "voidbot.provider_advertisement_catalog",
schemaId: "voidbot.provider_advertisement_catalog.v0",
schemaVersion: "voidbot.provider_advertisement_catalog.v0",
global: true,
schema: parseObjectDocument("VoidBot provider advertisement catalog"),
});
return {
interfaceBindingDefinition,
evePluginAdvertisementDefinition,
ghostlightSchemaCatalogDefinition,
heimdallCommandBoundaryDefinition,
heimdallTransportProfileDefinition,
interfaceLayoutDefinition,
idunnCommandBoundaryDefinition,
idunnDaemonHealthDefinition,
idunnTransportProfileDefinition,
muninnCaptureStreamCommandDefinition,
muninnCaptureStreamDefinition,
muninnCommandBoundaryDefinition,
muninnMediaAudioPacketDefinition,
muninnMediaReceiverFeedbackDefinition,
muninnMediaVideoAccessUnitDefinition,
muninnHidControllerStateDefinition,
muninnMoveControllerStateDefinition,
muninnMoveIdentityDefinition,
muninnMoveLightCommandDefinition,
muninnMoveMarkerCandidateDefinition,
muninnObsStreamCatalogDefinition,
muninnQuestAccessDefinition,
muninnTelemetrySurfaceDefinition,
muninnTransportProfileDefinition,
SleipnirInputMappingDefinition,
aetheriaAssetManifestDefinition,
aetheriaGravityViewportDefinition,
aetheriaObjectsViewportDefinition,
aetheriaRenderSplatsViewportDefinition,
operatorStateDefinition,
providerAdvertisementDefinition,
stonksCommandBoundaryDefinition,
stonksMarketSnapshotDefinition,
stonksRequestEventDefinition,
stonksTransportProfileDefinition,
streamPixelsCommandBoundaryDefinition,
streamPixelsTransportProfileDefinition,
surfaceDefinition,
viliCommandBoundaryDefinition,
viliTransportProfileDefinition,
weksaCommandBoundaryDefinition,
weksaOperatorStateDefinition,
weksaTransportProfileDefinition,
voidbotProviderCatalogDefinition,
voidbotSwarmSnapshotDefinition,
};
}

View file

@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import { exec, execFile } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, join, resolve, delimiter } from "node:path";
import { test } from "node:test";
import { promisify } from "node:util";
@ -24,7 +25,9 @@ const pythonCommand = process.env.PYTHON ?? "python";
const cultCacheTsRoot = resolve(__dirname, "../..");
const cultLibRoot = findAncestor(cultCacheTsRoot, "CultLib.sln") ?? resolve(cultCacheTsRoot, "..", "CultLib");
const cultcachePyRoot = resolve(cultLibRoot, "packages", "cultcache-py");
const cultcachePySrc = resolve(cultcachePyRoot, "src");
const cultcachePySrc = ["cultcache-py", "cultnet-py", "cultmesh-py"]
.map((name) => resolve(cultLibRoot, "packages", name, "src"))
.join(delimiter);
const cultcacheRsRoot = existsSync(resolve(cultLibRoot, "packages", "cultcache-rs"))
? resolve(cultLibRoot, "packages", "cultcache-rs")
: resolve(cultCacheTsRoot, "..", "cultcache-rs");
@ -161,8 +164,8 @@ test("CultCache can register name and index lookups after entries already exist"
body: "world",
});
cache.registerNameLookup(noteDocument, "title");
cache.registerIndex(noteDocument, "author", "author");
await cache.registerNameLookup(noteDocument, "title");
await cache.registerIndex(noteDocument, "author", "author");
assert.equal(cache.getIdByName(noteDocument, "Hello"), "note:hello");
assert.equal(cache.getIdByIndex(noteDocument, "author", "ari"), "note:hello");
@ -437,6 +440,58 @@ test("SingleFileMessagePackBackingStore reads CultCache v1 snapshots by schema i
assert.equal(cache.getRequiredEnvelope(noteDocument, "record-1").schemaId, "schema-1");
});
test("SingleFileMessagePackBackingStore recovers schema-stamped records missing catalog entries", async () => {
const stampedDocument = defineDocumentType({
type: "runtime-policy",
schema: z.tuple([
z.literal("tests.schema_stamped_entry.v1"),
z.string(),
z.string(),
]),
schemaId: "schema-current",
schemaName: "tests.schema_stamped_entry",
schemaVersion: "tests.schema_stamped_entry.v1",
compatibleSchemaIds: ["schema-current"],
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-ts-")), "missing-catalog.msgpack");
await writeFile(
storePath,
encode([
"cultcache.store.v1",
[],
[
[
"record-1",
"sha256:stale-schema-id-from-cold-record",
"2026-06-25T12:00:00Z",
encode([
"tests.schema_stamped_entry.v1",
"schema-stamped",
"still readable",
]),
],
],
]),
);
const cache = CultCache.builder()
.withDocumentType(stampedDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.getRequired(stampedDocument, "record-1"), [
"tests.schema_stamped_entry.v1",
"schema-stamped",
"still readable",
]);
assert.equal(
cache.getRequiredEnvelope(stampedDocument, "record-1").schemaId,
"sha256:stale-schema-id-from-cold-record",
);
});
test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was persisted as an object", async () => {
const noteDocument = defineDocumentType({
type: "note",
@ -479,6 +534,536 @@ test("SingleFileMessagePackBackingStore heals legacy envelopes whose payload was
assert.ok(records[0]?.[3] instanceof Uint8Array);
});
test("CultCache rejects a second generic backing store", async () => {
const cache = new CultCache();
await cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")));
await assert.rejects(
cache.addGenericBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc"))),
/second generic store/u,
);
});
test("CultCache rejects a type registered to two stores", async () => {
const cache = new CultCache();
await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-a.cc")), "item");
await assert.rejects(
cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-b.cc")), "settings", "item"),
/"item" is already routed/u,
);
// A refused registration claims nothing, so "settings" is still free.
await cache.addBackingStore(new SingleFileMessagePackBackingStore(join(tmpdir(), "unused-c.cc")), "settings");
});
test("CultCache routes each type to its home store", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({ name: z.string() }),
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ theme: z.string() }),
});
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-routes-"));
const genericPath = join(tempDir, "generic.cc");
const settingsPath = join(tempDir, "settings.cc");
const build = () => CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
.withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
const keysIn = async (path: string) =>
(await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`);
const cache = build();
await cache.put(itemDocument, "potion", { name: "Potion" });
await cache.put(settingsDocument, "app", { theme: "ash" });
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
assert.deepEqual(await keysIn(settingsPath), ["settings:app"]);
await cache.delete(settingsDocument, "app");
assert.deepEqual(await keysIn(settingsPath), []);
assert.deepEqual(await keysIn(genericPath), ["item:potion"]);
const reloaded = build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getRequired(itemDocument, "potion"), { name: "Potion" });
assert.equal(reloaded.get(settingsDocument, "app"), undefined);
});
test("CultCache refuses an attach that would move a held record's home", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-home-move-"));
const genericPath = join(tempDir, "generic.cc");
const settingsPath = join(tempDir, "settings.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
await cache.put(settingsDocument, "app", { theme: "v1-generic" });
await assert.rejects(
async () => cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument),
/would move "settings" from the generic store to the store routed to settings/u,
);
// Nothing attached: the next write still lands in the generic store, and the refused store stays untouched.
await cache.put(settingsDocument, "app", { theme: "v2-generic" });
const keysIn = async (path: string) =>
(await new SingleFileMessagePackBackingStore(path).pullAll()).map((entry) => `${entry.type}:${entry.key}`);
assert.deepEqual(await keysIn(genericPath), ["settings:app"]);
assert.equal(existsSync(settingsPath), false);
});
test("CultCache refuses at load a record delivered by a store that is not its home", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-load-home-"));
const genericPath = join(tempDir, "generic.cc");
const settingsPath = join(tempDir, "settings.cc");
const writer = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
await writer.put(settingsDocument, "app", { theme: "stray" });
const genericBytes = await readFile(genericPath);
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument)
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
// The cache already holds a record, in its home store, before the refused load.
await cache.put(settingsDocument, "held", { theme: "home" });
const before = cache.snapshot();
const settingsBytes = await readFile(settingsPath);
await assert.rejects(
cache.pullAllBackingStores(),
/"settings" record "app" was loaded from the generic store, but its home is the store routed to settings/u,
);
assert.equal(cache.get(settingsDocument, "app"), undefined);
assert.deepEqual(cache.snapshot(), before);
assert.deepEqual(cache.getRequired(settingsDocument, "held"), { theme: "home" });
assert.deepEqual(await readFile(genericPath), genericBytes);
assert.deepEqual(await readFile(settingsPath), settingsBytes);
});
async function storeState(path: string): Promise<string[]> {
if (!existsSync(path)) {
return [];
}
return (await new SingleFileMessagePackBackingStore(path).pullAll())
.map((entry) => `${entry.type}:${entry.key}:${Buffer.from(entry.payload).toString("hex")}`)
.sort();
}
test("putWithThrowingAccessorChangesNeitherStoreNorCache", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({ n: z.string() }),
name: (value: { n: string }) => {
if (value.n === "bad") {
throw new Error("accessor refuses bad");
}
return value.n;
},
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-put-accessor-")), "generic.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "k", { n: "good" });
const storeBefore = await storeState(storePath);
const cacheBefore = cache.snapshot();
await assert.rejects(cache.put(itemDocument, "k", { n: "bad" }), /accessor refuses bad/u);
assert.deepEqual(await storeState(storePath), storeBefore);
assert.deepEqual(cache.snapshot(), cacheBefore);
assert.deepEqual(cache.getRequired(itemDocument, "k"), { n: "good" });
assert.equal(cache.getKeyByName(itemDocument, "good"), "k");
});
test("overwriteUnderRegisteredAccessorThatNowThrowsChangesNeitherStoreNorCache", async () => {
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ c: z.string() }) });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-overwrite-accessor-")), "generic.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "k", { c: "old" });
let refusing = false;
await cache.registerIndex(itemDocument, "category", (value: { c: string }) => {
if (refusing) {
throw new Error("accessor refuses now");
}
return value.c;
});
refusing = true;
const storeBefore = await storeState(storePath);
const cacheBefore = cache.snapshot();
await assert.rejects(cache.put(itemDocument, "k", { c: "new" }), /accessor refuses now/u);
assert.deepEqual(await storeState(storePath), storeBefore);
assert.deepEqual(cache.snapshot(), cacheBefore);
assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), "k");
// Deleting runs no accessor: it uses the lookup values stored when the record was admitted.
assert.equal(await cache.delete(itemDocument, "k"), true);
assert.equal(cache.getKeyByIndex(itemDocument, "category", "old"), undefined);
});
test("registeringAccessorThatThrowsOnHeldRecordInstallsNothing", async () => {
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-register-accessor-")), "generic.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "old", { n: "bad" });
await assert.rejects(
async () => cache.registerIndex(itemDocument, "i", (value: { n: string }) => {
if (value.n === "bad") {
throw new Error("index refuses bad");
}
return value.n;
}),
/index refuses bad/u,
);
await cache.put(itemDocument, "old", { n: "fine" });
assert.deepEqual(cache.getRequired(itemDocument, "old"), { n: "fine" });
assert.equal(cache.getKeyByIndex(itemDocument, "i", "fine"), undefined);
});
test("globalReplaceWithFailingStorePushKeepsOldGlobal", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ t: z.string() }),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-push-")), "generic.cc");
const inner = new SingleFileMessagePackBackingStore(storePath);
let failPush = false;
const store: CacheBackingStore = {
pullAll: () => inner.pullAll(),
delete: (entry) => inner.delete(entry),
pushAll: (entries, options) => inner.pushAll(entries, options),
push: async (entry) => {
if (failPush) {
throw new Error("disk full");
}
return inner.push(entry);
},
};
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(store)
.build();
await cache.putGlobal(settingsDocument, { t: "base" });
const storeBefore = await storeState(storePath);
const cacheBefore = cache.snapshot();
failPush = true;
await assert.rejects(cache.putGlobal(settingsDocument, { t: "new" }), /disk full/u);
await assert.rejects(cache.put(settingsDocument, "other", { t: "new" }), /must use key "__global__"/u);
assert.deepEqual(await storeState(storePath), storeBefore);
assert.deepEqual(cache.snapshot(), cacheBefore);
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "base" });
});
test("concurrentGlobalPutsCannotBothReachDisk", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ t: z.string() }),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-global-race-")), "generic.cc");
const inner = new SingleFileMessagePackBackingStore(storePath);
// The first push after arming writes, then pauses until released. A second write that reaches the
// store during that pause is recorded: serialized writes cannot.
let armed = false;
let paused = false;
let reachedStoreWhilePaused = false;
let release!: () => void;
let reportPaused!: () => void;
const pausedSignal = new Promise<void>((resolve) => {
reportPaused = resolve;
});
const store: CacheBackingStore = {
pullAll: () => inner.pullAll(),
delete: (entry) => inner.delete(entry),
push: async (entry) => {
reachedStoreWhilePaused ||= paused;
await inner.push(entry);
if (armed) {
armed = false;
paused = true;
await new Promise<void>((resolve) => {
release = resolve;
reportPaused();
});
paused = false;
}
},
};
const build = (backing: CacheBackingStore) => CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(backing)
.build();
const cache = build(store);
await cache.putGlobal(settingsDocument, { t: "base" });
armed = true;
const first = cache.putGlobal(settingsDocument, { t: "first" });
await pausedSignal;
const second = cache.putGlobal(settingsDocument, { t: "second" });
await new Promise((resolve) => setTimeout(resolve, 50));
release();
await Promise.all([first, second]);
assert.equal(reachedStoreWhilePaused, false);
const onDisk = await inner.pullAll();
assert.deepEqual(onDisk.map((entry) => entry.key), [CultCache.GLOBAL_KEY]);
assert.deepEqual(
Buffer.from(onDisk[0].payload),
Buffer.from(cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY).payload),
);
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "second" });
const reloaded = build(new SingleFileMessagePackBackingStore(storePath));
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "second" });
});
test("unawaitedFailingPutIsAnUnhandledRejection", async () => {
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
const store: CacheBackingStore = {
pullAll: async () => [],
push: async () => {
throw new Error("disk full");
},
delete: async () => undefined,
};
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(store)
.build();
const runnerListeners = process.listeners("unhandledRejection");
process.removeAllListeners("unhandledRejection");
const unhandled: unknown[] = [];
const listener = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", listener);
try {
void cache.put(itemDocument, "k", { n: "x" });
await new Promise((resolve) => setTimeout(resolve, 50));
} finally {
process.off("unhandledRejection", listener);
for (const runnerListener of runnerListeners) {
process.on("unhandledRejection", runnerListener);
}
}
assert.equal(unhandled.length, 1);
assert.match(String((unhandled[0] as Error).message), /disk full/u);
// The chain survived the rejection: the next operation still runs.
await assert.rejects(cache.put(itemDocument, "k", { n: "y" }), /disk full/u);
});
test("storeChangeHandlerWritingBackQueuesAfterTheOperation", async () => {
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) });
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-writeback-")), "generic.cc");
const inner = new SingleFileMessagePackBackingStore(storePath);
const changes = new EventEmitter();
const pushed: string[] = [];
const store: CacheBackingStore = {
pullAll: () => inner.pullAll(),
delete: (entry) => inner.delete(entry),
push: async (entry) => {
await inner.push(entry);
pushed.push(entry.key);
changes.emit("pushed", entry.key);
},
};
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(store)
.build();
// The handler writes back and the store does not wait for it: that write queues behind the put.
const writeBacks: Promise<unknown>[] = [];
changes.on("pushed", (key: string) => {
if (key === "k") {
writeBacks.push(cache.put(itemDocument, "side", { n: "side" }));
}
});
await cache.put(itemDocument, "k", { n: "x" });
await Promise.all(writeBacks);
assert.equal(writeBacks.length, 1);
assert.deepEqual(pushed, ["k", "side"]);
assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" });
assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" });
assert.deepEqual(
(await inner.pullAll()).map((entry) => entry.key).sort(),
cache.snapshot().map((entry) => entry.key).sort(),
);
});
test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => {
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ t: z.string() }),
global: true,
});
const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-legacy-global-")), "generic.cc");
const build = () => CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
const seed = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build();
await seed.putGlobal(settingsDocument, { t: "old" });
const legacy = { ...seed.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY), key: "legacy" };
await new SingleFileMessagePackBackingStore(storePath).push(legacy);
const bytes = await readFile(storePath);
const cache = build();
await cache.pullAllBackingStores();
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { t: "old" });
assert.deepEqual(cache.get(settingsDocument, CultCache.GLOBAL_KEY), { t: "old" });
assert.deepEqual(await readFile(storePath), bytes);
await cache.putGlobal(settingsDocument, { t: "new" });
assert.deepEqual(
(await new SingleFileMessagePackBackingStore(storePath).pullAll()).map((entry) => entry.key),
[CultCache.GLOBAL_KEY],
);
const reloaded = build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getRequiredGlobal(settingsDocument), { t: "new" });
// Deleting an adopted global removes the legacy record.
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy]);
const deleting = build();
await deleting.pullAllBackingStores();
assert.equal(await deleting.deleteGlobal(settingsDocument), true);
assert.deepEqual(await new SingleFileMessagePackBackingStore(storePath).pullAll(), []);
// Two globals of one type on disk, under any keys, are refused.
await new SingleFileMessagePackBackingStore(storePath).pushAll([legacy, { ...legacy, key: CultCache.GLOBAL_KEY }]);
await assert.rejects(build().pullAllBackingStores(), /has multiple persisted entries/u);
});
test("concurrentPutAndAttachCannotLandRecordInNonHomeStore", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ t: z.string() }) });
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-put-attach-race-"));
const genericPath = join(tempDir, "generic.cc");
const settingsPath = join(tempDir, "settings.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
const pending = cache.put(settingsDocument, "app", { t: "x" });
const attach = Promise.resolve().then(() =>
cache.addBackingStore(new SingleFileMessagePackBackingStore(settingsPath), settingsDocument),
);
await pending;
await assert.rejects(attach, /would move "settings" from the generic store/u);
assert.deepEqual(
(await new SingleFileMessagePackBackingStore(genericPath).pullAll()).map((entry) => entry.key),
["app"],
);
assert.equal(existsSync(settingsPath), false);
const reloaded = CultCache.builder()
.withRegistry(defineDocumentRegistry(settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(genericPath))
.build();
await reloaded.pullAllBackingStores();
assert.deepEqual(reloaded.getRequired(settingsDocument, "app"), { t: "x" });
});
test("pullWithDuplicateGlobalLeavesCacheUnchanged", async () => {
const itemDocument = defineDocumentType({ type: "item", schema: z.object({ name: z.string() }) });
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ theme: z.string() }),
global: true,
});
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-duplicate-global-"));
const storePath = join(tempDir, "generic.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "potion", { name: "Potion" });
await cache.putGlobal(settingsDocument, { theme: "ash" });
const before = cache.snapshot();
// A second record of the global type lands in the store file behind the cache's back.
const stray = cache.getRequiredEnvelope(settingsDocument, CultCache.GLOBAL_KEY);
await new SingleFileMessagePackBackingStore(storePath).push({ ...stray, key: "stray" });
await assert.rejects(cache.pullAllBackingStores(), /has multiple persisted entries/u);
assert.deepEqual(cache.snapshot(), before);
assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion" });
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" });
});
test("pullWithThrowingIndexAccessorLeavesCacheUnchanged", async () => {
const itemDocument = defineDocumentType({
type: "item",
schema: z.object({ name: z.string(), category: z.string() }),
name: "name",
indexes: {
category: (item: { name: string; category: string }) => {
if (item.name === "Bomb") {
throw new Error("category accessor refuses Bomb");
}
return item.category;
},
},
});
const settingsDocument = defineDocumentType({
type: "settings",
schema: z.object({ theme: z.string() }),
global: true,
});
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-throwing-accessor-"));
const storePath = join(tempDir, "generic.cc");
const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument, settingsDocument))
.withGenericStore(new SingleFileMessagePackBackingStore(storePath))
.build();
await cache.put(itemDocument, "potion", { name: "Potion", category: "consumable" });
await cache.putGlobal(settingsDocument, { theme: "ash" });
const before = cache.snapshot();
// A record whose index accessor throws lands in the store file behind the cache's back.
const potion = cache.getRequiredEnvelope(itemDocument, "potion");
await new SingleFileMessagePackBackingStore(storePath).push({
...potion,
key: "bomb",
payload: encode({ name: "Bomb", category: "explosive" }),
});
await assert.rejects(cache.pullAllBackingStores(), /category accessor refuses Bomb/u);
assert.deepEqual(cache.snapshot(), before);
assert.deepEqual(cache.getRequired(itemDocument, "potion"), { name: "Potion", category: "consumable" });
assert.equal(cache.get(itemDocument, "bomb"), undefined);
assert.equal(cache.getKeyByName(itemDocument, "Potion"), "potion");
assert.equal(cache.getKeyByName(itemDocument, "Bomb"), undefined);
assert.equal(cache.getKeyByIndex(itemDocument, "category", "consumable"), "potion");
assert.deepEqual(cache.getRequiredGlobal(settingsDocument), { theme: "ash" });
});
test("CultCache with zero stores writes and deletes in memory", async () => {
const settingsDocument = defineDocumentType({ type: "settings", schema: z.object({ theme: z.string() }) });
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(settingsDocument)).build();
await cache.put(settingsDocument, "x", { theme: "mem" });
assert.deepEqual(cache.getRequired(settingsDocument, "x"), { theme: "mem" });
assert.equal(await cache.delete(settingsDocument, "x"), true);
assert.equal(cache.get(settingsDocument, "x"), undefined);
});
test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Python", async () => {
await buildInteropPeers();
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-interop-"));
@ -560,6 +1145,32 @@ test("CultCache v1 MessagePack stores are readable across TS, Rust, C#, and Pyth
assert.ok(read.tags.includes("interop"));
}
}
// C# write-routed attaches one home store per type: each file is a complete v1 snapshot holding only its own records.
const catalogFile = join(tempDir, "catalog.cc");
const runFile = join(tempDir, "run.cc");
const routed = await runJsonCommand("csharp-write-routed", dotnetCommand, [
csharpInteropDll,
"write-routed",
catalogFile,
runFile,
], cultLibRoot);
const recordsIn = async (file: string) => {
const decoded = decode(await readFile(file)) as unknown[];
assert.equal(decoded[0], "cultcache.store.v1");
assert.ok(Array.isArray(decoded[1]), `${file} has no schema catalog`);
return (decoded[2] as unknown[][]).map((record) => ({ schemaId: record[1], key: record[0] }));
};
const catalogRecords = await recordsIn(catalogFile);
const runRecords = await recordsIn(runFile);
assert.deepEqual(catalogRecords.map((record) => record.key), ["note:csharp-routed"]);
assert.deepEqual(runRecords.map((record) => record.key), ["run-note:csharp-routed"]);
assert.notEqual(catalogRecords[0]?.schemaId, runRecords[0]?.schemaId);
for (const reader of readers) {
const read = await reader.read(catalogFile);
assert.equal(read.documentId, routed.documentId, `${reader.name} failed to read the routed catalog store`);
assert.equal(read.body, "One home store per document type.");
}
});
test("CultCache interop reader accepts missing compatible trailing slots and rejects mismatched slots", async () => {
@ -604,6 +1215,42 @@ test("CultCache inspector decodes v1 store catalog and record payloads from byte
]);
});
test("CultCache inspector recovers schema-stamped records missing catalog entries", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "cultcache-inspector-"));
const file = join(tempDir, "missing-catalog.cc");
await writeFile(
file,
encode([
"cultcache.store.v1",
[],
[
[
"record-1",
"sha256:stale-schema-id-from-cold-record",
"2026-06-25T12:00:00Z",
encode([
"tests.schema_stamped_entry.v1",
"schema-stamped",
"still readable",
]),
],
],
]),
);
const inspection = inspectCultCacheBytes(file, await readFile(file));
assert.equal(inspection.catalog.length, 1);
assert.equal(inspection.catalog[0]?.schemaId, "sha256:stale-schema-id-from-cold-record");
assert.equal(inspection.catalog[0]?.schemaName, "tests.schema_stamped_entry");
assert.equal(inspection.records[0]?.schemaName, "tests.schema_stamped_entry");
assert.deepEqual(inspection.records[0]?.payloadPreview, [
"tests.schema_stamped_entry.v1",
"schema-stamped",
"still readable",
]);
});
interface InteropNote {
schemaVersion: "cultcache.interop_note.v1";
documentId: string;

9
packages/cultmath/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
bin/
obj/
**/bin/
**/obj/
target/
**/target/
/native/cultmath-core/target.meta
.tools/
/artifacts/

136
packages/cultmath/README.md Normal file
View file

@ -0,0 +1,136 @@
# CultMath
CultMath is the project name. Its clean .NET package identity is
`GameCult.Math`, owned and released from the CultLib monorepo.
Clean-room math shaped for people who think in HLSL.
CultMath provides lowercase vector types, shader-style intrinsics, and native
hot-path kernels so math-heavy renderer code can move between C#, Rust, and HLSL
without turning every line into a translation exercise. It is not a
Unity.Mathematics clone. It is a small, Aquarium-born library built from public
shader semantics.
## Current Surface
- `float2`, `float3`, `float4`
- component-wise arithmetic
- scalar-to-vector splats
- every HLSL swizzle (`xyzw` and `rgba`, repeats readable, non-repeating
masks writable) and every HLSL mixed constructor, generated by
`tools/generate-swizzles.ps1`
- conversions to and from `System.Numerics`
- `Random`, a compact deterministic XORShift stream with scalar and vector draws
- `BatchMath` SoA kernels over `Span<float>` / `ReadOnlySpan<float>` using
`System.Numerics.Vector<float>` when available, including radial falloff
acceleration and semi-implicit Euler integration
- `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`,
`min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`,
`cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential
`decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and
deterministic value-noise primitives
- `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side
parity. HLSL already owns `float2`, `float3`, and `float4`; the include
exposes `cultmath_*` functions for shared semantics, including CultMath's
safe `normalize` contract.
- `Voronoi.SampleTones`, a C# batch surface that calls the Rust
`cultmath-core` native kernel when `cultmath_core` is available and falls back
to the managed parity path otherwise.
## Native Core
The canonical hot-path body lives in `native/cultmath-core`. It exports a C ABI
for batch kernels such as `cultmath_apollonian_voronoi_tones`.
Runtime wrappers own syntax and integration. Rust owns optimized primitive
kernel behavior and parity fixtures.
## Example
```csharp
using CultMath;
float3 normal = math.normalize(new float3(0.25f, 1.0f, -0.1f));
float rim = math.saturate(1.0f - math.dot(normal, new float3(0.0f, 0.0f, -1.0f)));
float glow = math.smoothstep(0.2f, 1.0f, rim);
float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f));
```
```hlsl
#include "CultMath.hlsl"
float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1));
float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0)));
float glow = cultmath_smoothstep(0.2, 1.0, rim);
float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5));
```
## Provenance
The small spline, damping, and pursuit helpers were promoted from Aetheria's
local `AetheriaMath` surface. The value-noise family follows public shader
literature from Inigo Quilez's noise and mini-spline articles, expressed in
CultMath names with tests instead of copied project-local helper drift.
## Build
```powershell
dotnet build packages/cultmath/src/CultMath/CultMath.csproj
dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj
cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml
.\packages\cultmath\scripts\build-unity-package.ps1
```
## Unity package
CultMath's Unity 2021.3 surface is the tracked precompiled package at
`packages/cultmath/unity/org.gamecult.cultmath`. Unity consumes `CultMath.dll` and the numeric
`CultMath.hlsl`; it does not compile the repository's current-language C# source
tree and does not require a consumer `csc.rsp` workaround.
```json
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath"
```
The repository root is not a Unity package. Build the tracked package with
`packages/cultmath/scripts/build-unity-package.ps1`; the script fresh-builds the netstandard2.1
assembly and rejects stale binaries, missing metadata, unexpected assemblies,
or leaked C# source.
## Shader Tooling
CultMath keeps portable DXC outside git under `packages/cultmath/.tools/`:
```powershell
.\packages\cultmath\tools\get-dxc.ps1
.\packages\cultmath\tools\get-spirv-cross.ps1
.\packages\cultmath\tools\compile-hlsl-spirv.ps1 `
-ShaderPath E:\Projects\Odin\crates\muninn-move-tracker\shaders\MoveSphereCandidate.comp.hlsl `
-OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv `
-IncludePath E:\Projects\CultLib\packages\cultmath\shaders
.\packages\cultmath\tools\compile-spirv-metal.ps1 `
-SpirvPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.spv `
-OutputPath E:\Projects\Odin\crates\muninn-move-tracker\artifacts\shader\MoveSphereCandidate.comp.metal
```
DXC emits Vulkan SPIR-V with `-spirv`. `-TargetEnv vulkan1.2` is exposed by the
script, but the current Windows DXC release compiled this shader successfully
with DXC's default Vulkan target while rejecting the dotted target-env value.
The SPIR-V script applies default Vulkan register shifts for cbuffers, textures,
UAVs, and samplers so cross-compiled Metal buffers do not alias by resource
class. SPIRV-Cross emits Metal Shading Language from SPIR-V on Windows; building
an iPad-ready `.metallib` still belongs to Apple's Metal compiler on an Apple
toolchain.
## Doctrine
The goal is mechanical sympathy between CPU-side authoring, native kernels, and
GPU-side shader math. If an intrinsic exists here, it should behave like the
shader concept unless a runtime's numeric rules make that impossible. Weird
deviations get tests and documentation, not folklore.
CultMath is still not a shader generator. Projects should include
`shaders/CultMath.hlsl` directly or add that directory to their shader include
path. If a shared operation needs CPU/GPU parity, put the primitive here and
test it here before copying local math helpers into a renderer or daemon.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 867daebafe0ce48479cf043461ee6bdf
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,11 @@
# Third-Party Notices
## 2D and 3D Simplex Noise
`math.snoise` and `cultmath_snoise` port the textureless 2D and 3D
simplex-noise functions by Ian McEwan and Ashima Arts, as distributed by Stefan
Gustavson's `webgl-noise` project and Unity Mathematics.
- Copyright 2011 Ashima Arts.
- Upstream: <https://github.com/ashima/webgl-noise>
- License: MIT.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 7c28c8631c7d4054eb751d7ce952df33
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: de535b89253410b41b5989f712b80af4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,143 @@
# Design
CultMath exists to make renderer math readable in every place it needs to live:
Rust hot kernels, C# host code, and HLSL shader code.
## Parity Target: HLSL
HLSL is the semantic authority for every type and intrinsic HLSL defines. The
goal is source compatibility: HLSL-shaped math compiles as C# under
`using static CultMath.math;`. C# forces float literal suffixes (`0.5f`); the
rest of the spelling should survive the move.
That makes these HLSL rules, not options:
- Every swizzle exists. `float2/3/4`, `int2/3/4`, and `bool2/3/4` expose every
read swizzle of length 2 to 4, repeats included (`v.wwxy`), in both `xyzw` and
`rgba` naming (never mixed), plus single-component `r/g/b/a`. `double2/3` get
lengths 2 and 3 only, because there is no `double4`. Components and swizzles are
writable: `v.xz = f2;`, `c.rgb *= 0.5f;`, and `v[1] = s;` work. Setters exist
only for non-repeating masks, because HLSL rejects `v.xx = ...`.
- Every HLSL mixed constructor exists, as a struct constructor and as a `math`
function: `float4(float2, float2)`, `float4(float, float2, float)`,
`float3(float, float2)`, the copy form `float3(float3)`, and the rest, for the
float, int, and bool families (double gets constructors only; a `math.double3`
function would hide the `double3` type name).
- Swizzles and mixed constructors are generated. `tools/generate-swizzles.ps1`
writes the checked-in `src/CultMath/Swizzles.g.cs` (partial structs plus a
partial `math`). Edit the generator, rerun it, and commit both; never edit the
output by hand.
- Comparison operators (`<`, `>`, `<=`, `>=`, `==`, `!=`) are component-wise and
return `bool2`/`bool3`/`bool4`. Reduce them with `any` and `all`.
`Equals`/`GetHashCode` stay value-based so vectors still work as dictionary
keys.
- `select(condition, whenTrue, whenFalse)` uses HLSL's argument order.
- Matrices are row-major: constructors take rows, `m[i]` returns a reference to
row i (so `m[1][2] = s;` writes one element), `_mRC` is row R column C, and
products go through `mul` (`mul(m, v)` treats `v` as a column, `mul(v, m)` as a
row). `*` on matrices would be component-wise in HLSL, so CultMath does not
define it.
- `m[r][c] = v` writes through locals, array elements, fields of classes, and
ref locals. Through a readonly field, an `in` parameter, a property
(auto-properties included), or a static readonly value such as
`float3x3.identity`, C# takes a defensive copy: the write compiles and is
silently lost. Copy the matrix to a local, write to it, and assign it back.
- Scalar/vector arithmetic and comparisons have explicit overloads on both
sides rather than leaning on implicit scalar splat.
- Intrinsics return HLSL's types and edge cases: `sign` returns `int`/`intN`
(NaN gives 0); `step(y, x)` is `x < y ? 0 : 1` (NaN gives 1), which is how dxc
lowers it, in C# and in the HLSL mirror; float and double `min` and `max`, and
float `clamp` and `saturate`, follow the DXIL `FMin`/`FMax`/`Saturate` operations dxc emits for
them: `min(a, b)` is `a < b ? a : b`, `max(a, b)` is `a >= b ? a : b`, a NaN
operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is
`min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0
to +0; `any`/`all` accept numeric vectors (a component is true when it is
not 0); `min`, `max`, and `abs` on int vectors return int vectors.
dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is
`precise`, so drivers may optimize NaN handling away and NaN results on real
GPUs are not guaranteed. CultMath defines the CPU result by dxc's lowering and
the DXIL operation spec (DirectXShaderCompiler `docs/DXIL.rst`).
NaN does not reliably propagate through CultMath, just as it does not on the
GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some
functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0;
infinite inputs to `smoothstep` and the intercept helpers give numbers; the
bezier, `smoothstep01`, and `smootherstep` functions return finite values for
NaN input; and `normalize((NaN, 2.41))` is `(NaN, 2.41e20)`, because the NaN
length is clamped to the epsilon and the other component is divided by it.
Callers that must detect bad data should check `isnan` before calling these
functions.
Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`,
and HLSL's one-based `_11`..`_33` element names.
### HLSL Target: Source Transformations
`HlslSourceCompatibilityTests` compiles the real `shaders/CultMath.hlsl` with
Roslyn against CultMath. These transformations are the complete list; if a body
needs anything else, the test fails:
1. Include guards (`#ifndef`/`#define`/`#endif`) are dropped.
2. Functions taking `Texture2D`/`SamplerState` are dropped; GPU resources have
no CultMath analog.
3. File-scope `static const` becomes `const`.
4. Floating literals gain `f` (`0.5` becomes `0.5f`).
5. The file body is wrapped in a class, since C# has no free functions; HLSL
functions become private instance methods.
HLSL constructs that stay outside the target and must not appear in the mirror:
`const` locals of vector type (C# `const` is primitives only; write a plain
local) and writes through a swizzle component (`v.xy.x = s`, which C# rejects as
modifying a temporary).
The HLSL side is checked too: `tools/get-dxc.ps1` fetches Microsoft's
DirectXShaderCompiler, and
`dxc -T lib_6_3 -HV 2021 shaders/CultMath.hlsl` must compile cleanly.
The mirror test compares bit patterns (any NaN equals any NaN; -0 differs from
0). It proves the text of `CultMath.hlsl` computes the same float32 results as
C# `math` on the CPU, not that a GPU agrees: driver `sin` precision alone makes
`cultmath_hash` and value noise differ bit for bit on hardware. The mirror is
compiled under `using static CultMath.math;`, so its intrinsics are C# `math`
itself: the test proves the composition in the text, not the intrinsic rules,
which only `HlslSemanticsTests` pins.
Where HLSL is silent, CultMath keeps its own decisions and does not defer to
Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`),
`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped
helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`,
`snoise`) enter only when a consumer needs them, with their semantics stated in
code.
Known C# friction: under `using static CultMath.math;` the constructor
functions (`float3(...)`, `float3x3(...)`) hide the type names in member access,
so static members need a qualified type (`CultMath.float3x3.Euler(...)`).
## Rules
- Keep type names HLSL-shaped: `float2`, `float3`, `float4`, `float3x3`, and `math`.
- Keep Rust as the owner of native hot-path kernels and parity fixtures.
- Keep C#, shader, and scripting surfaces as runtime wrappers, not competing
math authorities.
- Keep `shaders/CultMath.hlsl` as the canonical shader mirror for shared
CultMath functions HLSL does not already provide (HLSL intrinsics such as
`mul`, `any`, and `log` need no mirror). Project-local shader helpers should
move here once more than one organ needs them or once CPU/GPU parity matters.
- Implement public shader semantics directly; do not copy Unity.Mathematics.
- Prefer plain mutable value types with public component fields, as HLSL does.
- Prefer component-wise overloads over clever generic machinery until the API
earns that complexity.
- Keep the surface boring and exact. Fill what a consumer uses, guided by HLSL
semantics; noise primitives, matrices, quaternions, and packing helpers need an
owner before they enter.
- Keep the core assembly engine-free. Unity conversions live in the Unity
package's `CultMath.UnityBridge` assembly.
## Non-Goals
- Compiling C# to shaders.
- Generating HLSL from Rust or Rust from HLSL.
- Replacing `System.Numerics` for general .NET work.
- Recreating every Unity.Mathematics type because a spreadsheet somewhere says
“coverage.” That way lies decorative obesity.

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 07021a8be816e3448a9490c10845b0aa
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9d1cb2ac88c530b4d9da456523115e12
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a42d65478346c354c8b4f353c5581b49
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cultmath-core"
version = "0.1.0"

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f1c365f7eead2684ba621124e653bdab
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,13 @@
[package]
name = "cultmath-core"
version = "0.1.0"
edition = "2021"
[lib]
name = "cultmath_core"
crate-type = ["cdylib", "staticlib"]
[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cdd2d4eab053b764b9a06d36b080e43e
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7de448b7e258d2c448bb7391051cc628
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,213 @@
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct CultMathColor32 {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
#[no_mangle]
pub unsafe extern "C" fn cultmath_apollonian_voronoi_tones(
xs: *const f32,
ys: *const f32,
tones: *const u8,
spans: *const f32,
count: usize,
resolution_y: f32,
frame_index: i32,
out_colors: *mut CultMathColor32,
) -> i32 {
if xs.is_null() || ys.is_null() || tones.is_null() || spans.is_null() || out_colors.is_null() {
return -1;
}
let xs = unsafe { std::slice::from_raw_parts(xs, count) };
let ys = unsafe { std::slice::from_raw_parts(ys, count) };
let tones = unsafe { std::slice::from_raw_parts(tones, count) };
let spans = unsafe { std::slice::from_raw_parts(spans, count) };
let out_colors = unsafe { std::slice::from_raw_parts_mut(out_colors, count) };
for index in 0..count {
out_colors[index] = tone_color(
xs[index],
ys[index],
resolution_y,
frame_index,
tones[index],
spans[index],
);
}
0
}
fn tone_color(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> CultMathColor32 {
let sample = stable_sample(px, py, resolution_y, frame_index, tone, span);
let luminance = saturate(sample.0 * 0.299 + sample.1 * 0.587 + sample.2 * 0.114);
match tone {
3 => {
let glow = 0.62 + luminance * 0.38;
color(255.0 * glow, 96.0 * glow, 14.0 * glow)
}
4 => {
let glow = 0.68 + luminance * 0.32;
color(245.0 * glow, 248.0 * glow, 252.0 * glow)
}
_ => {
let (gain, lift) = match tone {
1 => (0.42, 14.0),
2 => (1.05, 24.0),
_ => (0.24, 4.0),
};
color(
lift + sample.0 * 255.0 * gain,
lift + sample.1 * 255.0 * gain,
lift + sample.2 * 255.0 * gain,
)
}
}
}
fn stable_sample(px: f32, py: f32, resolution_y: f32, frame_index: i32, tone: u8, span: f32) -> (f32, f32, f32) {
let seed = px * 12.9898 + py * 78.233 + hash1((tone as f32 + 1.0) * 19.19);
let jitter_x = (hash1(seed) - 0.5) * span;
let jitter_y = (hash1(seed + 37.17) - 0.5) * span;
voronoi(px + jitter_x, py + jitter_y, resolution_y, frame_index)
}
fn voronoi(px: f32, py: f32, resolution_y: f32, frame_index: i32) -> (f32, f32, f32) {
let time = frame_index as f32 / 120.0;
let scale = 6.0;
let x = scale * px / resolution_y.max(1.0);
let y = scale * py / resolution_y.max(1.0);
let nx = x.floor();
let ny = y.floor();
let fx = frac(x);
let fy = frac(y);
let mut distance = 8.0;
let mut red = 0.0;
let mut green = 0.0;
let mut blue = 0.0;
let smoothness = 0.005;
for j in -2..=2 {
for i in -2..=2 {
let gx = i as f32;
let gy = j as f32;
let (mut ox, mut oy) = hash2(nx + gx, ny + gy);
let weight = ox * 0.5 + 0.5;
ox = 0.5 + 0.5 * (time + 6.2831 * ox).sin();
oy = 0.5 + 0.5 * (time + 6.2831 * oy).sin();
let dx = (gx - fx + ox).abs();
let dy = (gy - fy + oy).abs();
let d = dx.max(dy) * weight;
let seed = hash1((nx + gx) * 7.0 + (ny + gy) * 113.0);
let hue = frac((nx + gx) * 0.173 + (ny + gy) * 0.379 + seed * 0.431);
let (candidate_red, candidate_green, candidate_blue) = pastel_spectrum(hue);
let h = smoothstep(0.0, 1.0, 0.5 + 0.5 * (distance - d) / smoothness);
let correction = h * (1.0 - h) * smoothness / (1.0 + 3.0 * smoothness);
distance = lerp(distance, d, h) - correction;
red = lerp(red, candidate_red, h) - correction;
green = lerp(green, candidate_green, h) - correction;
blue = lerp(blue, candidate_blue, h) - correction;
}
}
let edge_dimming = 1.0 - 0.1 * smoothstep(0.04, 0.05, distance);
(
(red * edge_dimming).max(0.0),
(green * edge_dimming).max(0.0),
(blue * edge_dimming).max(0.0),
)
}
fn hash1(value: f32) -> f32 {
frac(value.sin() * 43758.5453)
}
fn hash2(x: f32, y: f32) -> (f32, f32) {
(
frac((x * 127.1 + y * 311.7).sin() * 43758.5453),
frac((x * 269.5 + y * 183.3).sin() * 43758.5453),
)
}
fn frac(value: f32) -> f32 {
value - value.floor()
}
fn saturate(value: f32) -> f32 {
value.clamp(0.0, 1.0)
}
fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
let t = saturate((value - edge0) / (edge1 - edge0));
t * t * (3.0 - 2.0 * t)
}
fn lerp(start: f32, end: f32, amount: f32) -> f32 {
start + (end - start) * amount
}
fn pastel_spectrum(hue: f32) -> (f32, f32, f32) {
hsv_to_rgb(hue, 0.52, 0.98)
}
fn hsv_to_rgb(hue: f32, saturation: f32, value: f32) -> (f32, f32, f32) {
let h = frac(hue) * 6.0;
let c = value * saturation;
let x = c * (1.0 - ((h % 2.0) - 1.0).abs());
let m = value - c;
let (r, g, b) = if h < 1.0 {
(c, x, 0.0)
} else if h < 2.0 {
(x, c, 0.0)
} else if h < 3.0 {
(0.0, c, x)
} else if h < 4.0 {
(0.0, x, c)
} else if h < 5.0 {
(x, 0.0, c)
} else {
(c, 0.0, x)
};
(r + m, g + m, b + m)
}
fn color(r: f32, g: f32, b: f32) -> CultMathColor32 {
CultMathColor32 {
r: r.clamp(0.0, 255.0) as u8,
g: g.clamp(0.0, 255.0) as u8,
b: b.clamp(0.0, 255.0) as u8,
a: 255,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_sampler_writes_colors() {
let xs = [0.0, 20.0, 100.0];
let ys = [0.0, 30.0, 140.0];
let tones = [0, 3, 4];
let spans = [1920.0, 8.0, 8.0];
let mut out = [CultMathColor32::default(); 3];
let rc = unsafe {
cultmath_apollonian_voronoi_tones(
xs.as_ptr(),
ys.as_ptr(),
tones.as_ptr(),
spans.as_ptr(),
xs.len(),
1080.0,
12,
out.as_mut_ptr(),
)
};
assert_eq!(rc, 0);
assert!(out.iter().any(|color| color.r != 0 || color.g != 0 || color.b != 0));
}
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b06c69c432a633f4584830d6b28d4473
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0ddff5b782cf14e43ae5cda048e040d8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,159 @@
param(
[string] $Configuration = "Release",
[string] $OutputDirectory = "artifacts\unity\org.gamecult.cultmath"
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot "src\CultMath\CultMath.csproj"
$templateRoot = Join-Path $repoRoot "unity\org.gamecult.cultmath"
$buildRoot = Join-Path $repoRoot "artifacts\cultmath-unity-build"
$outputRoot = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) {
$OutputDirectory
} else {
Join-Path $repoRoot $OutputDirectory
}
$freshAssembly = Join-Path $buildRoot "CultMath.dll"
$freshSymbols = Join-Path $buildRoot "CultMath.pdb"
$trackedPluginRoot = Join-Path $templateRoot "Runtime\Plugins"
$trackedAssembly = Join-Path $trackedPluginRoot "CultMath.dll"
$trackedSymbols = Join-Path $trackedPluginRoot "CultMath.pdb"
if (Test-Path -LiteralPath $buildRoot) {
Remove-Item -LiteralPath $buildRoot -Recurse -Force
}
if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
# The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link
# writes the commit SHA into the pdb, and the DLL carries that pdb's content id.
dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental `
--disable-build-servers -p:UseSharedCompilation=false -p:ContinuousIntegrationBuild=true -p:EnableSourceLink=false -m:1
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity assembly build failed with exit code $LASTEXITCODE"
}
foreach ($path in @($freshAssembly, $freshSymbols)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "CultMath Unity build is missing $path"
}
}
$requiredTemplateFiles = @(
"LICENSE.md",
"LICENSE.md.meta",
"README.md",
"README.md.meta",
"THIRD-PARTY-NOTICES.md",
"THIRD-PARTY-NOTICES.md.meta",
"package.json",
"package.json.meta",
"Runtime\Plugins\CultMath.dll",
"Runtime\Plugins\CultMath.dll.meta",
"Runtime\Plugins\CultMath.pdb",
"Runtime\Plugins\CultMath.pdb.meta",
"Runtime\UnityBridge\CultMath.UnityBridge.asmdef",
"Runtime\UnityBridge\CultMath.UnityBridge.asmdef.meta",
"Runtime\UnityBridge\UnityConversions.cs",
"Runtime\UnityBridge\UnityConversions.cs.meta",
"Shaders\CultMath.hlsl",
"Shaders\CultMath.hlsl.meta"
)
foreach ($relativePath in $requiredTemplateFiles) {
if (-not (Test-Path -LiteralPath (Join-Path $templateRoot $relativePath) -PathType Leaf)) {
throw "Tracked CultMath Unity package is incomplete: missing $relativePath"
}
}
$unityAssetFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File | Where-Object {
$_.Extension -in @(".asmdef", ".cs", ".dll", ".pdb", ".hlsl")
})
$missingAssetMetas = @($unityAssetFiles | Where-Object { -not (Test-Path -LiteralPath ($_.FullName + ".meta")) })
$missingDirectoryMetas = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -Directory | Where-Object {
-not (Test-Path -LiteralPath ($_.FullName + ".meta"))
})
if ($missingAssetMetas.Count -ne 0 -or $missingDirectoryMetas.Count -ne 0) {
$missing = @($missingAssetMetas.FullName) + @($missingDirectoryMetas.FullName)
throw "Tracked CultMath Unity package has assets without stable .meta files: $($missing -join ', ')"
}
$manifest = Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json
$pluginMeta = Get-Content -LiteralPath ($trackedAssembly + ".meta") -Raw
if ($pluginMeta -notmatch '(?m)^\s*isExplicitlyReferenced:\s*0\s*$') {
throw "CultMath.dll must be auto-referenced; explicit-only plugins do not reach dependent Unity asmdefs"
}
# The precompiled DLL is the only authority for CultMath's numeric types. The one
# compiled assembly allowed beside it is the UnityEngine conversion bridge, which
# references that DLL and must not re-declare core sources.
$bridgeRoot = Join-Path $templateRoot "Runtime\UnityBridge"
$bridgeDefinition = Join-Path $bridgeRoot "CultMath.UnityBridge.asmdef"
$definitions = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.asmdef")
if ($definitions.Count -ne 1 -or $definitions[0].FullName -ne $bridgeDefinition) {
throw "CultMath's Unity package may define only the Runtime\UnityBridge assembly; found: $($definitions.FullName -join ', ')"
}
$bridge = Get-Content -LiteralPath $bridgeDefinition -Raw | ConvertFrom-Json
if ($bridge.noEngineReferences -or -not $bridge.overrideReferences -or @($bridge.precompiledReferences) -notcontains "CultMath.dll") {
throw "CultMath.UnityBridge must reference UnityEngine and the precompiled CultMath.dll"
}
$expectedFileVersion = "$($manifest.version).0"
$trackedFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($trackedAssembly).FileVersion
$freshFileVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($freshAssembly).FileVersion
if ($trackedFileVersion -ne $expectedFileVersion -or $freshFileVersion -ne $expectedFileVersion) {
throw "CultMath Unity assemblies must match package version $expectedFileVersion; tracked=$trackedFileVersion fresh=$freshFileVersion"
}
foreach ($pair in @(@($freshAssembly, $trackedAssembly), @($freshSymbols, $trackedSymbols))) {
$freshHash = (Get-FileHash -LiteralPath $pair[0] -Algorithm SHA256).Hash
$trackedHash = (Get-FileHash -LiteralPath $pair[1] -Algorithm SHA256).Hash
if ($freshHash -ne $trackedHash) {
throw "Tracked Unity artifact is stale: $($pair[1]) differs from a fresh deterministic build"
}
}
$trackedSourceFiles = @(Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Filter "*.cs")
$straySources = @($trackedSourceFiles | Where-Object { $_.DirectoryName -ne $bridgeRoot })
if ($trackedSourceFiles.Count -eq 0 -or $straySources.Count -ne 0) {
throw "CultMath Unity package may compile only bridge sources under Runtime\UnityBridge: $($straySources.FullName -join ', ')"
}
# Check what the bridge declares, not what its files are called: it may declare types only in
# CultMath.UnityBridge and never a type name the core CultMath sources declare.
$declarationPattern = '\b(?:struct|class|enum|interface|record|delegate\s+[\w<>.,\s]+?)\s+(\w+)'
function Get-CodeWithoutComments([string]$path) {
$text = Get-Content -LiteralPath $path -Raw
return [regex]::Replace($text, '(?s)/\*.*?\*/|//[^\r\n]*', '')
}
$coreTypeNames = @(Get-ChildItem -LiteralPath (Join-Path $repoRoot "src\CultMath") -File -Filter "*.cs" | ForEach-Object {
[regex]::Matches((Get-CodeWithoutComments $_.FullName), $declarationPattern) | ForEach-Object { $_.Groups[1].Value }
} | Sort-Object -Unique)
if ($coreTypeNames -notcontains "float3" -or $coreTypeNames -notcontains "math") {
throw "Could not read CultMath core type declarations; the bridge check would be vacuous"
}
foreach ($source in $trackedSourceFiles) {
$code = Get-CodeWithoutComments $source.FullName
$redeclared = @([regex]::Matches($code, $declarationPattern) | ForEach-Object { $_.Groups[1].Value } | Where-Object { $coreTypeNames -contains $_ })
$foreignNamespaces = @([regex]::Matches($code, '\bnamespace\s+([\w.]+)') | ForEach-Object { $_.Groups[1].Value } | Where-Object { $_ -ne "CultMath.UnityBridge" })
if ($redeclared.Count -ne 0 -or $foreignNamespaces.Count -ne 0) {
throw "CultMath.UnityBridge source $($source.Name) re-declares core types [$($redeclared -join ', ')] or uses namespaces [$($foreignNamespaces -join ', ')]; the precompiled DLL owns CultMath's types"
}
}
New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null
Copy-Item -Path (Join-Path $templateRoot "*") -Destination $outputRoot -Recurse -Force
$stagedAssemblies = @(Get-ChildItem -LiteralPath (Join-Path $outputRoot "Runtime\Plugins") -Filter "*.dll")
$stagedBridgeRoot = Join-Path $outputRoot "Runtime\UnityBridge"
$stagedSources = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.cs")
$stagedDefinitions = @(Get-ChildItem -LiteralPath $outputRoot -Recurse -File -Filter "*.asmdef")
$stagedStraySources = @($stagedSources | Where-Object { $_.DirectoryName -ne $stagedBridgeRoot })
if ($stagedAssemblies.Count -ne 1 -or $stagedAssemblies[0].Name -ne "CultMath.dll" -or $stagedStraySources.Count -ne 0 -or $stagedDefinitions.Count -ne 1) {
throw "CultMath package must contain one owned auto-referenced DLL and only the UnityBridge assembly as source"
}
Write-Host "CultMath Unity package: $outputRoot"
Write-Host "Package: $($manifest.name)@$($manifest.version)"
Write-Host "Managed assemblies: $($stagedAssemblies.Count)"
Write-Host "Bridge C# source files: $($stagedSources.Count)"
Write-Host "Asmdef definitions: $($stagedDefinitions.Count)"

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3bed74ebcba445543800218dbe185034
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,356 @@
#ifndef CULTMATH_HLSL
#define CULTMATH_HLSL
// CultMath HLSL mirror.
//
// HLSL already owns the float2/float3/float4 type names and most primitive
// intrinsics. This include names the CultMath semantic surface explicitly so
// shader code, C# wrappers, and Rust/native parity fixtures can share the same
// small vocabulary without local helper drift.
static const float CULTMATH_PI = 3.14159265358979323846;
static const float CULTMATH_TAU = 6.28318530717958647692;
static const float CULTMATH_HALF_PI = 1.57079632679489661923;
static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20;
float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); }
float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); }
float cultmath_frac(float value) { return value - floor(value); }
float2 cultmath_frac(float2 value) { return value - floor(value); }
float3 cultmath_frac(float3 value) { return value - floor(value); }
float4 cultmath_frac(float4 value) { return value - floor(value); }
float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); }
float2 cultmath_clamp(float2 value, float2 minimum, float2 maximum) { return min(max(value, minimum), maximum); }
float3 cultmath_clamp(float3 value, float3 minimum, float3 maximum) { return min(max(value, minimum), maximum); }
float4 cultmath_clamp(float4 value, float4 minimum, float4 maximum) { return min(max(value, minimum), maximum); }
// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included.
float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); }
float2 cultmath_saturate(float2 value) { return min(float2(1.0, 1.0), max(float2(0.0, 0.0), value)); }
float3 cultmath_saturate(float3 value) { return min(float3(1.0, 1.0, 1.0), max(float3(0.0, 0.0, 0.0), value)); }
float4 cultmath_saturate(float4 value) { return min(float4(1.0, 1.0, 1.0, 1.0), max(float4(0.0, 0.0, 0.0, 0.0), value)); }
float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; }
float2 cultmath_lerp(float2 start, float2 end, float2 amount) { return start + (end - start) * amount; }
float3 cultmath_lerp(float3 start, float3 end, float3 amount) { return start + (end - start) * amount; }
float4 cultmath_lerp(float4 start, float4 end, float4 amount) { return start + (end - start) * amount; }
float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; }
float2 cultmath_step(float2 edge, float2 value) { return float2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); }
float3 cultmath_step(float3 edge, float3 value) { return float3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); }
float4 cultmath_step(float4 edge, float4 value) { return float4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); }
float cultmath_smoothstep(float minimum, float maximum, float value)
{
float t = cultmath_saturate((value - minimum) / (maximum - minimum));
return t * t * (3.0 - 2.0 * t);
}
float2 cultmath_smoothstep(float2 minimum, float2 maximum, float2 value)
{
float2 t = cultmath_saturate((value - minimum) / (maximum - minimum));
return t * t * (float2(3.0, 3.0) - float2(2.0, 2.0) * t);
}
float3 cultmath_smoothstep(float3 minimum, float3 maximum, float3 value)
{
float3 t = cultmath_saturate((value - minimum) / (maximum - minimum));
return t * t * (float3(3.0, 3.0, 3.0) - float3(2.0, 2.0, 2.0) * t);
}
float4 cultmath_smoothstep(float4 minimum, float4 maximum, float4 value)
{
float4 t = cultmath_saturate((value - minimum) / (maximum - minimum));
return t * t * (float4(3.0, 3.0, 3.0, 3.0) - float4(2.0, 2.0, 2.0, 2.0) * t);
}
float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); }
float cultmath_smootherstep(float value)
{
value = cultmath_saturate(value);
return value * value * value * (value * (value * 6.0 - 15.0) + 10.0);
}
float2 cultmath_snoise_mod289(float2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_mod289(float3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float4 cultmath_snoise_mod289(float4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; }
float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 1.0) * value); }
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
// order as the C# math.snoise(float3) mirror.
float cultmath_snoise(float3 value)
{
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
float3 x1 = x0 - i1 + c.xxx;
float3 x2 = x0 - i2 + c.yyy;
float3 x3 = x0 - 0.5;
i = cultmath_snoise_mod289(i);
float4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute(
i.z + float4(0.0, i1.z, i2.z, 1.0)) + i.y + float4(0.0, i1.y, i2.y, 1.0)) + i.x + float4(0.0, i1.x, i2.x, 1.0));
const float n_ = 0.142857142857;
float3 ns = float3(2.0 * n_, 0.5 * n_ - 1.0, n_);
float4 j = p - 49.0 * floor(p * ns.z * ns.z);
float4 x_ = floor(j * ns.z);
float4 y_ = floor(j - 7.0 * x_);
float4 x = x_ * ns.x + ns.yyyy;
float4 y = y_ * ns.x + ns.yyyy;
float4 h = 1.0 - abs(x) - abs(y);
float4 b0 = float4(x.xy, y.xy);
float4 b1 = float4(x.zw, y.zw);
float4 s0 = floor(b0) * 2.0 + 1.0;
float4 s1 = floor(b1) * 2.0 + 1.0;
float4 sh = -step(h, 0.0);
float4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
float4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
float3 p0 = float3(a0.xy, h.x);
float3 p1 = float3(a0.zw, h.y);
float3 p2 = float3(a1.xy, h.z);
float3 p3 = float3(a1.zw, h.w);
float4 norm = 1.79284291400159 - 0.85373472095314 * float4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
float4 m = max(0.6 - float4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, float4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
float cultmath_snoise(float2 value)
{
float4 c = float4(
0.211324865405187,
0.366025403784439,
-0.577350269189626,
0.024390243902439);
float2 i = floor(value + dot(value, c.yy));
float2 x0 = value - i + dot(i, c.xx);
float2 i1 = x0.x > x0.y ? float2(1.0, 0.0) : float2(0.0, 1.0);
float4 x12 = float4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z);
i = cultmath_snoise_mod289(i);
float3 p = cultmath_snoise_permute(
cultmath_snoise_permute(i.y + float3(0.0, i1.y, 1.0)) + i.x + float3(0.0, i1.x, 1.0));
float3 m = max(0.5 - float3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m *= m;
m *= m;
float3 x = 2.0 * frac(p * c.w) - 1.0;
float3 h = abs(x) - 0.5;
float3 ox = floor(x + 0.5);
float3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
float3 g = float3(
a0.x * x0.x + h.x * x0.y,
a0.y * x12.x + h.y * x12.y,
a0.z * x12.z + h.z * x12.w);
return 130.0 * dot(m, g);
}
float cultmath_lengthsq(float2 value) { return dot(value, value); }
float cultmath_lengthsq(float3 value) { return dot(value, value); }
float cultmath_lengthsq(float4 value) { return dot(value, value); }
float cultmath_distance(float2 left, float2 right) { return length(left - right); }
float cultmath_distance(float3 left, float3 right) { return length(left - right); }
float cultmath_distance(float4 left, float4 right) { return length(left - right); }
float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); }
float2 cultmath_reflect(float2 incident, float2 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
}
float3 cultmath_reflect(float3 incident, float3 normal_value)
{
return incident - 2.0 * dot(normal_value, incident) * normal_value;
}
float2 cultmath_rotate(float2 value, float radians_value)
{
float s = sin(radians_value);
float c = cos(radians_value);
return float2(value.x * c - value.y * s, value.x * s + value.y * c);
}
float2 cultmath_rotate_degrees(float2 value, float degrees_value)
{
return cultmath_rotate(value, cultmath_radians(degrees_value));
}
float cultmath_csum(float2 value) { return value.x + value.y; }
float cultmath_csum(float3 value) { return value.x + value.y + value.z; }
float cultmath_csum(float4 value) { return value.x + value.y + value.z + value.w; }
float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
float2 cultmath_decay(float2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
float3 cultmath_decay(float3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
float4 cultmath_decay(float4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); }
float cultmath_damp(float start, float end, float lambda_value, float dt)
{
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
}
float2 cultmath_damp(float2 start, float2 end, float lambda_value, float dt)
{
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
}
float3 cultmath_damp(float3 start, float3 end, float lambda_value, float dt)
{
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
}
float4 cultmath_damp(float4 start, float4 end, float lambda_value, float dt)
{
return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt));
}
float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
}
float2 cultmath_catmullrom(float2 p0, float2 p1, float2 p2, float2 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
}
float3 cultmath_catmullrom(float3 p0, float3 p1, float3 p2, float3 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
}
float4 cultmath_catmullrom(float4 p0, float4 p1, float4 p2, float4 p3, float t)
{
float t2 = t * t;
float t3 = t2 * t;
return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
}
float cultmath_quadratic_bezier(float p0, float p1, float p2, float t)
{
t = cultmath_saturate(t);
float inv = 1.0 - t;
return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2;
}
float3 cultmath_quadratic_bezier(float3 p0, float3 p1, float3 p2, float t)
{
t = cultmath_saturate(t);
float inv = 1.0 - t;
return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2;
}
float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t)
{
t = cultmath_saturate(t);
float inv = 1.0 - t;
return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3;
}
float3 cultmath_cubic_bezier(float3 p0, float3 p1, float3 p2, float3 p3, float t)
{
t = cultmath_saturate(t);
float inv = 1.0 - t;
return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3;
}
float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); }
float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); }
float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); }
float cultmath_value_noise(float2 position)
{
float2 cell = floor(position);
float2 local = cultmath_frac(position);
float2 u = local * local * (float2(3.0, 3.0) - float2(2.0, 2.0) * local);
float a = cultmath_hash(cell);
float b = cultmath_hash(cell + float2(1.0, 0.0));
float c = cultmath_hash(cell + float2(0.0, 1.0));
float d = cultmath_hash(cell + float2(1.0, 1.0));
return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y);
}
float cultmath_value_noise_bicubic(float2 position)
{
float2 cell = floor(position);
float2 local = cultmath_frac(position);
float y0 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, -1.0)), cultmath_hash(cell + float2(0.0, -1.0)), cultmath_hash(cell + float2(1.0, -1.0)), cultmath_hash(cell + float2(2.0, -1.0)), local.x);
float y1 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 0.0)), cultmath_hash(cell + float2(0.0, 0.0)), cultmath_hash(cell + float2(1.0, 0.0)), cultmath_hash(cell + float2(2.0, 0.0)), local.x);
float y2 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 1.0)), cultmath_hash(cell + float2(0.0, 1.0)), cultmath_hash(cell + float2(1.0, 1.0)), cultmath_hash(cell + float2(2.0, 1.0)), local.x);
float y3 = cultmath_catmullrom(cultmath_hash(cell + float2(-1.0, 2.0)), cultmath_hash(cell + float2(0.0, 2.0)), cultmath_hash(cell + float2(1.0, 2.0)), cultmath_hash(cell + float2(2.0, 2.0)), local.x);
return cultmath_catmullrom(y0, y1, y2, y3, local.y);
}
float cultmath_value_noise_texture(Texture2D<float> noise_texture, SamplerState noise_sampler, float2 uv, float scale)
{
return noise_texture.SampleLevel(noise_sampler, uv * scale, 0.0).r;
}
float cultmath_value_noise_texture_bicubic(Texture2D<float> noise_texture, SamplerState noise_sampler, float2 uv, float scale, float2 texel_size)
{
float2 position = uv * scale;
float2 cell = floor(position);
float2 local = cultmath_frac(position);
float2 base_uv = cell / scale;
float row0 = cultmath_catmullrom(
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, -1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, -1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, -1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, -1.0), 0.0).r,
local.x);
float row1 = cultmath_catmullrom(
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 0.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 0.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 0.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 0.0), 0.0).r,
local.x);
float row2 = cultmath_catmullrom(
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 1.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 1.0), 0.0).r,
local.x);
float row3 = cultmath_catmullrom(
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(-1.0, 2.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(0.0, 2.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(1.0, 2.0), 0.0).r,
noise_texture.SampleLevel(noise_sampler, base_uv + texel_size * float2(2.0, 2.0), 0.0).r,
local.x);
return cultmath_catmullrom(row0, row1, row2, row3, local.y);
}
#endif

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 33174674795143c4a905408718f956f8
ShaderIncludeImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 64f48d0cae0810e4ba9903e9094fe4c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e857b457b93397d4fb4ad8481cb72e4c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,245 @@
using System;
using System.Numerics;
namespace CultMath;
public static class BatchMath
{
private const float MinimumDistanceSquared = 1.0e-8f;
public static int LaneCount => Vector<float>.Count;
public static bool IsHardwareAccelerated => Vector.IsHardwareAccelerated;
public static void Clear(Span<float> values) => values.Clear();
public static void AddRadialFalloffAcceleration2D(
ReadOnlySpan<float> positionX,
ReadOnlySpan<float> positionY,
float centerX,
float centerY,
float strength,
float radius,
Span<float> accelerationX,
Span<float> accelerationY)
{
ValidateEqualLengths(positionX.Length, positionY.Length, accelerationX.Length, accelerationY.Length);
if (radius <= 0.0f || strength == 0.0f || positionX.Length == 0)
{
return;
}
var radiusVector = new Vector<float>(radius);
var centerXVector = new Vector<float>(centerX);
var centerYVector = new Vector<float>(centerY);
var strengthVector = new Vector<float>(strength);
var one = Vector<float>.One;
var zero = Vector<float>.Zero;
var minimumDistanceSquared = new Vector<float>(MinimumDistanceSquared);
var i = 0;
var lanes = Vector<float>.Count;
var pxBuffer = new float[lanes];
var pyBuffer = new float[lanes];
var axBuffer = new float[lanes];
var ayBuffer = new float[lanes];
for (; i <= positionX.Length - lanes; i += lanes)
{
CopySpanToArray(positionX.Slice(i, lanes), pxBuffer);
CopySpanToArray(positionY.Slice(i, lanes), pyBuffer);
CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer);
CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer);
var px = new Vector<float>(pxBuffer);
var py = new Vector<float>(pyBuffer);
var dx = centerXVector - px;
var dy = centerYVector - py;
var distanceSquared = Vector.Max(dx * dx + dy * dy, minimumDistanceSquared);
var distance = Vector.SquareRoot(distanceSquared);
var inRange = Vector.LessThanOrEqual(distance, radiusVector);
var falloff = Vector.Max(zero, one - distance / radiusVector);
var magnitude = strengthVector * falloff;
var ax = Vector.ConditionalSelect(inRange, dx / distance * magnitude, zero);
var ay = Vector.ConditionalSelect(inRange, dy / distance * magnitude, zero);
(new Vector<float>(axBuffer) + ax).CopyTo(axBuffer);
(new Vector<float>(ayBuffer) + ay).CopyTo(ayBuffer);
CopyArrayToSpan(axBuffer, accelerationX.Slice(i, lanes));
CopyArrayToSpan(ayBuffer, accelerationY.Slice(i, lanes));
}
for (; i < positionX.Length; i++)
{
var dx = centerX - positionX[i];
var dy = centerY - positionY[i];
var distanceSquared = MathF.Max(dx * dx + dy * dy, MinimumDistanceSquared);
var distance = MathF.Sqrt(distanceSquared);
if (distance > radius)
{
continue;
}
var falloff = MathF.Max(0.0f, 1.0f - distance / radius);
var magnitude = strength * falloff;
accelerationX[i] += dx / distance * magnitude;
accelerationY[i] += dy / distance * magnitude;
}
}
public static void AddRadialFalloffAcceleration2D(
ReadOnlySpan<float2> positions,
float2 center,
float strength,
float radius,
Span<float2> acceleration)
{
ValidateEqualLengths(positions.Length, acceleration.Length);
if (radius <= 0.0f || strength == 0.0f || positions.Length == 0)
{
return;
}
for (var i = 0; i < positions.Length; i++)
{
var delta = center - positions[i];
var distanceSquared = MathF.Max(delta.x * delta.x + delta.y * delta.y, MinimumDistanceSquared);
var distance = MathF.Sqrt(distanceSquared);
if (distance > radius)
{
continue;
}
var falloff = MathF.Max(0.0f, 1.0f - distance / radius);
acceleration[i] += delta / distance * (strength * falloff);
}
}
public static void IntegrateSemiImplicitEuler2D(
float deltaTime,
ReadOnlySpan<float> dynamicMask,
Span<float> positionX,
Span<float> positionY,
Span<float> velocityX,
Span<float> velocityY,
ReadOnlySpan<float> accelerationX,
ReadOnlySpan<float> accelerationY)
{
if (deltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive.");
}
ValidateEqualLengths(
dynamicMask.Length,
positionX.Length,
positionY.Length,
velocityX.Length,
velocityY.Length,
accelerationX.Length,
accelerationY.Length);
var delta = new Vector<float>(deltaTime);
var i = 0;
var lanes = Vector<float>.Count;
var maskBuffer = new float[lanes];
var pxBuffer = new float[lanes];
var pyBuffer = new float[lanes];
var vxBuffer = new float[lanes];
var vyBuffer = new float[lanes];
var axBuffer = new float[lanes];
var ayBuffer = new float[lanes];
for (; i <= positionX.Length - lanes; i += lanes)
{
CopySpanToArray(dynamicMask.Slice(i, lanes), maskBuffer);
CopySpanToArray(velocityX.Slice(i, lanes), vxBuffer);
CopySpanToArray(velocityY.Slice(i, lanes), vyBuffer);
CopySpanToArray(positionX.Slice(i, lanes), pxBuffer);
CopySpanToArray(positionY.Slice(i, lanes), pyBuffer);
CopySpanToArray(accelerationX.Slice(i, lanes), axBuffer);
CopySpanToArray(accelerationY.Slice(i, lanes), ayBuffer);
var mask = new Vector<float>(maskBuffer);
var vx = new Vector<float>(vxBuffer);
var vy = new Vector<float>(vyBuffer);
var px = new Vector<float>(pxBuffer);
var py = new Vector<float>(pyBuffer);
var ax = new Vector<float>(axBuffer);
var ay = new Vector<float>(ayBuffer);
vx += ax * delta * mask;
vy += ay * delta * mask;
px += vx * delta * mask;
py += vy * delta * mask;
vx.CopyTo(vxBuffer);
vy.CopyTo(vyBuffer);
px.CopyTo(pxBuffer);
py.CopyTo(pyBuffer);
CopyArrayToSpan(vxBuffer, velocityX.Slice(i, lanes));
CopyArrayToSpan(vyBuffer, velocityY.Slice(i, lanes));
CopyArrayToSpan(pxBuffer, positionX.Slice(i, lanes));
CopyArrayToSpan(pyBuffer, positionY.Slice(i, lanes));
}
for (; i < positionX.Length; i++)
{
var mask = dynamicMask[i];
velocityX[i] += accelerationX[i] * deltaTime * mask;
velocityY[i] += accelerationY[i] * deltaTime * mask;
positionX[i] += velocityX[i] * deltaTime * mask;
positionY[i] += velocityY[i] * deltaTime * mask;
}
}
public static void IntegrateSemiImplicitEuler2D(
float deltaTime,
ReadOnlySpan<float> dynamicMask,
Span<float2> position,
Span<float2> velocity,
ReadOnlySpan<float2> acceleration)
{
if (deltaTime <= 0.0f)
{
throw new ArgumentOutOfRangeException(nameof(deltaTime), "Delta time must be positive.");
}
ValidateEqualLengths(dynamicMask.Length, position.Length, velocity.Length, acceleration.Length);
for (var i = 0; i < position.Length; i++)
{
var mask = dynamicMask[i];
velocity[i] += acceleration[i] * deltaTime * mask;
position[i] += velocity[i] * deltaTime * mask;
}
}
private static void ValidateEqualLengths(params int[] lengths)
{
var length = lengths[0];
for (var i = 1; i < lengths.Length; i++)
{
if (lengths[i] != length)
{
throw new ArgumentException("CultMath batch spans must have equal length.");
}
}
}
private static void CopySpanToArray(ReadOnlySpan<float> source, float[] destination)
{
for (var i = 0; i < source.Length; i++)
{
destination[i] = source[i];
}
}
private static void CopyArrayToSpan(float[] source, Span<float> destination)
{
for (var i = 0; i < destination.Length; i++)
{
destination[i] = source[i];
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4771320e190bdd408dd296c293a9d0e

View file

@ -0,0 +1,4 @@
namespace CultMath;
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public readonly record struct Color32(byte r, byte g, byte b, byte a = 255);

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bfa14a9aacb8ac4448761568643f2bc6

View file

@ -0,0 +1,14 @@
{
"name": "CultMath",
"rootNamespace": "CultMath",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": true,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f0b66b909d13e18468f7aa56221531e9
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;netstandard2.1</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>CultMath</RootNamespace>
<AssemblyName>CultMath</AssemblyName>
<PackageId>GameCult.Math</PackageId>
<Version>0.2.0</Version>
<Description>Clean-room C# math shaped for HLSL-style renderer code.</Description>
<Authors>GameCult</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>
<NoWarn>$(NoWarn);CS8981</NoWarn>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- The Unity DLL is committed beside its source, so its bytes must not name a commit. -->
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<None Include="..\..\shaders\CultMath.hlsl" Pack="true" PackagePath="contentFiles\any\any\shaders\CultMath.hlsl" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 77e1a0a2512910f41885650a7d0d6f60
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,10 @@
namespace CultMath;
public enum CultMathTone : byte
{
Background = 0,
Panel = 1,
Edge = 2,
Header = 3,
Body = 4,
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d4bfc66f9f7e992448f884c2c338d50a

View file

@ -0,0 +1,5 @@
namespace System.Runtime.CompilerServices;
internal static class IsExternalInit
{
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: de2b3c3e7f03c354aa2b84fbbc55b497

View file

@ -0,0 +1,66 @@
namespace CultMath;
public struct Random
{
private const uint DefaultSeed = 0x6E624EB7u;
public uint state;
public Random(uint seed)
{
state = seed == 0 ? DefaultSeed : seed;
}
public static Random CreateFromIndex(uint index) => new(index + DefaultSeed);
public void InitState(uint seed)
{
state = seed == 0 ? DefaultSeed : seed;
}
public uint NextUInt()
{
var x = state == 0 ? DefaultSeed : state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
state = x == 0 ? DefaultSeed : x;
return state;
}
public int NextInt(int max) => NextInt(0, max);
public int NextInt(int min, int max)
{
if (max <= min)
{
return min;
}
return min + (int)(NextUInt() % (uint)(max - min));
}
public float NextFloat() => (NextUInt() >> 8) * (1.0f / 16777216.0f);
public float NextFloat(float max) => NextFloat() * max;
public float NextFloat(float min, float max) => min + NextFloat() * (max - min);
public float2 NextFloat2() => new(NextFloat(), NextFloat());
public float2 NextFloat2(float min, float max) => new(NextFloat(min, max), NextFloat(min, max));
public float2 NextFloat2(float2 min, float2 max) => min + NextFloat2() * (max - min);
public float2 NextFloat2Direction()
{
var angle = NextFloat(0.0f, math.TAU);
return new float2(math.cos(angle), math.sin(angle));
}
public float3 NextFloat3() => new(NextFloat(), NextFloat(), NextFloat());
public float3 NextFloat3(float3 max) => NextFloat3() * max;
public float3 NextFloat3(float3 min, float3 max) => min + NextFloat3() * (max - min);
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c1a557259026da4ca20b54264852c13

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e89d6089b354459788eb6f0faebfd6c0

View file

@ -0,0 +1,10 @@
#if !NET7_0_OR_GREATER
namespace System.Diagnostics.CodeAnalysis;
// Compiler-recognized polyfill so netstandard2.1 builds can return refs into a struct's
// own fields (the matrix row indexer). net7.0+ supplies the real attribute.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
internal sealed class UnscopedRefAttribute : Attribute
{
}
#endif

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f204000f9854517a8dd754e2d9dc975

View file

@ -0,0 +1,229 @@
using System;
using System.Runtime.InteropServices;
namespace CultMath;
public static class Voronoi
{
public static bool NativeAvailable { get; private set; } = true;
public static Color32 SampleTone(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span)
{
Span<float> xs = stackalloc[] { x };
Span<float> ys = stackalloc[] { y };
Span<CultMathTone> tones = stackalloc[] { tone };
Span<float> spans = stackalloc[] { span };
Span<Color32> colors = stackalloc Color32[1];
SampleTones(xs, ys, tones, spans, resolutionY, frameIndex, colors);
return colors[0];
}
public static void SampleTones(
ReadOnlySpan<float> xs,
ReadOnlySpan<float> ys,
ReadOnlySpan<CultMathTone> tones,
ReadOnlySpan<float> spans,
float resolutionY,
int frameIndex,
Span<Color32> colors)
{
if (xs.Length != ys.Length || xs.Length != tones.Length || xs.Length != spans.Length || xs.Length != colors.Length)
{
throw new ArgumentException("CultMath Voronoi batch spans must have equal length.");
}
if (xs.IsEmpty)
{
return;
}
if (NativeAvailable && TrySampleNative(xs, ys, tones, spans, resolutionY, frameIndex, colors))
{
return;
}
for (var index = 0; index < xs.Length; index++)
{
colors[index] = SampleManaged(xs[index], ys[index], resolutionY, frameIndex, tones[index], spans[index]);
}
}
private static unsafe bool TrySampleNative(
ReadOnlySpan<float> xs,
ReadOnlySpan<float> ys,
ReadOnlySpan<CultMathTone> tones,
ReadOnlySpan<float> spans,
float resolutionY,
int frameIndex,
Span<Color32> colors)
{
try
{
fixed (float* xPtr = xs)
fixed (float* yPtr = ys)
fixed (CultMathTone* tonePtr = tones)
fixed (float* spanPtr = spans)
fixed (Color32* colorPtr = colors)
{
var result = Native.cultmath_apollonian_voronoi_tones(
xPtr,
yPtr,
(byte*)tonePtr,
spanPtr,
(nuint)xs.Length,
resolutionY,
frameIndex,
colorPtr);
if (result == 0)
{
return true;
}
}
}
catch (DllNotFoundException)
{
NativeAvailable = false;
}
catch (EntryPointNotFoundException)
{
NativeAvailable = false;
}
return false;
}
private static Color32 SampleManaged(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span)
{
var sample = StableSample(x, y, resolutionY, frameIndex, tone, span);
var luminance = math.saturate(sample.x * 0.299f + sample.y * 0.587f + sample.z * 0.114f);
if (tone == CultMathTone.Header)
{
var glow = 0.62f + luminance * 0.38f;
return new Color32((byte)(255 * glow), (byte)(96 * glow), (byte)(14 * glow));
}
if (tone == CultMathTone.Body)
{
var glow = 0.68f + luminance * 0.32f;
return new Color32((byte)(245 * glow), (byte)(248 * glow), (byte)(252 * glow));
}
var gain = tone switch
{
CultMathTone.Panel => 0.42f,
CultMathTone.Edge => 1.05f,
_ => 0.24f,
};
var lift = tone switch
{
CultMathTone.Panel => 14.0f,
CultMathTone.Edge => 24.0f,
_ => 4.0f,
};
return new Color32(
(byte)Math.Clamp(lift + sample.x * 255.0f * gain, 0, 255),
(byte)Math.Clamp(lift + sample.y * 255.0f * gain, 0, 255),
(byte)Math.Clamp(lift + sample.z * 255.0f * gain, 0, 255));
}
private static float3 StableSample(float x, float y, float resolutionY, int frameIndex, CultMathTone tone, float span)
{
var seed = x * 12.9898f + y * 78.233f + Hash1(((byte)tone + 1.0f) * 19.19f);
var jitter = new float2((Hash1(seed) - 0.5f) * span, (Hash1(seed + 37.17f) - 0.5f) * span);
return SampleField(x + jitter.x, y + jitter.y, resolutionY, frameIndex);
}
private static float3 SampleField(float px, float py, float resolutionY, int frameIndex)
{
var time = frameIndex / 120.0f;
var p = new float2(6.0f * px / Math.Max(1.0f, resolutionY), 6.0f * py / Math.Max(1.0f, resolutionY));
var n = math.floor(p);
var f = math.frac(p);
var distance = 8.0f;
var color = float3.zero;
const float smoothness = 0.005f;
for (var j = -2; j <= 2; j++)
{
for (var i = -2; i <= 2; i++)
{
var g = new float2(i, j);
var o = Hash2(n + g);
var weight = o.x * 0.5f + 0.5f;
o = 0.5f + 0.5f * new float2(math.sin(time + 6.2831f * o.x), math.sin(time + 6.2831f * o.y));
var d2 = math.abs(g - f + o);
var d = Math.Max(d2.x, d2.y) * weight;
var seed = Hash1(math.dot(n + g, new float2(7.0f, 113.0f)));
var hue = math.frac((n.x + g.x) * 0.173f + (n.y + g.y) * 0.379f + seed * 0.431f);
var candidate = PastelSpectrum(hue);
var h = math.smoothstep(0.0f, 1.0f, 0.5f + 0.5f * (distance - d) / smoothness);
var correction = h * (1.0f - h) * smoothness / (1.0f + 3.0f * smoothness);
distance = math.lerp(distance, d, h) - correction;
color = math.lerp(color, candidate, h) - correction;
}
}
return math.max(float3.zero, color * (1.0f - 0.1f * math.smoothstep(0.04f, 0.05f, distance)));
}
private static float Hash1(float value) => math.frac(math.sin(value) * 43758.5453f);
private static float3 PastelSpectrum(float hue)
{
return HsvToRgb(hue, 0.52f, 0.98f);
}
private static float3 HsvToRgb(float hue, float saturation, float value)
{
var h = math.frac(hue) * 6.0f;
var c = value * saturation;
var x = c * (1.0f - Math.Abs(h % 2.0f - 1.0f));
var m = value - c;
float3 rgb;
if (h < 1.0f)
{
rgb = new float3(c, x, 0.0f);
}
else if (h < 2.0f)
{
rgb = new float3(x, c, 0.0f);
}
else if (h < 3.0f)
{
rgb = new float3(0.0f, c, x);
}
else if (h < 4.0f)
{
rgb = new float3(0.0f, x, c);
}
else if (h < 5.0f)
{
rgb = new float3(x, 0.0f, c);
}
else
{
rgb = new float3(c, 0.0f, x);
}
return rgb + m;
}
private static float2 Hash2(float2 value)
{
var p = new float2(math.dot(value, new float2(127.1f, 311.7f)), math.dot(value, new float2(269.5f, 183.3f)));
return math.frac(new float2(math.sin(p.x) * 43758.5453f, math.sin(p.y) * 43758.5453f));
}
private static unsafe partial class Native
{
[DllImport("cultmath_core", CallingConvention = CallingConvention.Cdecl)]
public static extern int cultmath_apollonian_voronoi_tones(
float* xs,
float* ys,
byte* tones,
float* spans,
nuint count,
float resolutionY,
int frameIndex,
Color32* outColors);
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cb530f1428ac8ff43939b986986ae959

View file

@ -0,0 +1,51 @@
using System;
namespace CultMath;
public partial struct bool2 : IEquatable<bool2>
{
public static readonly bool2 @false = new(false, false);
public static readonly bool2 @true = new(true, true);
public bool x;
public bool y;
public bool2(bool x, bool y)
{
this.x = x;
this.y = y;
}
public bool this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator bool2(bool value) => new(value, value);
public static bool2 operator !(bool2 value) => new(!value.x, !value.y);
public static bool2 operator &(bool2 left, bool2 right) => new(left.x & right.x, left.y & right.y);
public static bool2 operator |(bool2 left, bool2 right) => new(left.x | right.x, left.y | right.y);
public static bool2 operator ^(bool2 left, bool2 right) => new(left.x ^ right.x, left.y ^ right.y);
public static bool2 operator ==(bool2 left, bool2 right) => new(left.x == right.x, left.y == right.y);
public static bool2 operator !=(bool2 left, bool2 right) => new(left.x != right.x, left.y != right.y);
public readonly bool Equals(bool2 other) => x == other.x && y == other.y;
public override readonly bool Equals(object? obj) => obj is bool2 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y);
public override readonly string ToString() => $"bool2({x}, {y})";
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 725efb2725e987449a3782452f5a95e0

View file

@ -0,0 +1,55 @@
using System;
namespace CultMath;
public partial struct bool3 : IEquatable<bool3>
{
public static readonly bool3 @false = new(false, false, false);
public static readonly bool3 @true = new(true, true, true);
public bool x;
public bool y;
public bool z;
public bool3(bool x, bool y, bool z)
{
this.x = x;
this.y = y;
this.z = z;
}
public bool this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
2 => z,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
case 2: z = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator bool3(bool value) => new(value, value, value);
public static bool3 operator !(bool3 value) => new(!value.x, !value.y, !value.z);
public static bool3 operator &(bool3 left, bool3 right) => new(left.x & right.x, left.y & right.y, left.z & right.z);
public static bool3 operator |(bool3 left, bool3 right) => new(left.x | right.x, left.y | right.y, left.z | right.z);
public static bool3 operator ^(bool3 left, bool3 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z);
public static bool3 operator ==(bool3 left, bool3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z);
public static bool3 operator !=(bool3 left, bool3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z);
public readonly bool Equals(bool3 other) => x == other.x && y == other.y && z == other.z;
public override readonly bool Equals(object? obj) => obj is bool3 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y, z);
public override readonly string ToString() => $"bool3({x}, {y}, {z})";
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 323ec670fc374c8ba4d87b4bf4c91f34

View file

@ -0,0 +1,59 @@
using System;
namespace CultMath;
public partial struct bool4 : IEquatable<bool4>
{
public static readonly bool4 @false = new(false, false, false, false);
public static readonly bool4 @true = new(true, true, true, true);
public bool x;
public bool y;
public bool z;
public bool w;
public bool4(bool x, bool y, bool z, bool w)
{
this.x = x;
this.y = y;
this.z = z;
this.w = w;
}
public bool this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
2 => z,
3 => w,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
case 2: z = value; break;
case 3: w = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator bool4(bool value) => new(value, value, value, value);
public static bool4 operator !(bool4 value) => new(!value.x, !value.y, !value.z, !value.w);
public static bool4 operator &(bool4 left, bool4 right) => new(left.x & right.x, left.y & right.y, left.z & right.z, left.w & right.w);
public static bool4 operator |(bool4 left, bool4 right) => new(left.x | right.x, left.y | right.y, left.z | right.z, left.w | right.w);
public static bool4 operator ^(bool4 left, bool4 right) => new(left.x ^ right.x, left.y ^ right.y, left.z ^ right.z, left.w ^ right.w);
public static bool4 operator ==(bool4 left, bool4 right) => new(left.x == right.x, left.y == right.y, left.z == right.z, left.w == right.w);
public static bool4 operator !=(bool4 left, bool4 right) => new(left.x != right.x, left.y != right.y, left.z != right.z, left.w != right.w);
public readonly bool Equals(bool4 other) => x == other.x && y == other.y && z == other.z && w == other.w;
public override readonly bool Equals(object? obj) => obj is bool4 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y, z, w);
public override readonly string ToString() => $"bool4({x}, {y}, {z}, {w})";
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d38b9f70d88c459584441b85ecbfd92a

View file

@ -0,0 +1,58 @@
using System;
namespace CultMath;
public partial struct double2 : IEquatable<double2>
{
public static readonly double2 zero = new(0.0, 0.0);
public static readonly double2 one = new(1.0, 1.0);
public double x;
public double y;
public double2(double x, double y)
{
this.x = x;
this.y = y;
}
public double this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator double2(double value) => new(value, value);
public static double2 operator +(double2 value) => value;
public static double2 operator -(double2 value) => new(-value.x, -value.y);
public static double2 operator +(double2 left, double2 right) => new(left.x + right.x, left.y + right.y);
public static double2 operator -(double2 left, double2 right) => new(left.x - right.x, left.y - right.y);
public static double2 operator *(double2 left, double2 right) => new(left.x * right.x, left.y * right.y);
public static double2 operator /(double2 left, double2 right) => new(left.x / right.x, left.y / right.y);
public static bool2 operator <(double2 left, double2 right) => new(left.x < right.x, left.y < right.y);
public static bool2 operator >(double2 left, double2 right) => new(left.x > right.x, left.y > right.y);
public static bool2 operator <=(double2 left, double2 right) => new(left.x <= right.x, left.y <= right.y);
public static bool2 operator >=(double2 left, double2 right) => new(left.x >= right.x, left.y >= right.y);
public static bool2 operator ==(double2 left, double2 right) => new(left.x == right.x, left.y == right.y);
public static bool2 operator !=(double2 left, double2 right) => new(left.x != right.x, left.y != right.y);
public readonly bool Equals(double2 other) => x.Equals(other.x) && y.Equals(other.y);
public override readonly bool Equals(object? obj) => obj is double2 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y);
public override readonly string ToString() => FormattableString.Invariant($"double2({x:R}, {y:R})");
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bbe739a5f0216c3459762d2b5ee7401a

View file

@ -0,0 +1,62 @@
using System;
namespace CultMath;
public partial struct double3 : IEquatable<double3>
{
public static readonly double3 zero = new(0.0, 0.0, 0.0);
public static readonly double3 one = new(1.0, 1.0, 1.0);
public double x;
public double y;
public double z;
public double3(double x, double y, double z)
{
this.x = x;
this.y = y;
this.z = z;
}
public double this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
2 => z,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
case 2: z = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator double3(double value) => new(value, value, value);
public static double3 operator +(double3 value) => value;
public static double3 operator -(double3 value) => new(-value.x, -value.y, -value.z);
public static double3 operator +(double3 left, double3 right) => new(left.x + right.x, left.y + right.y, left.z + right.z);
public static double3 operator -(double3 left, double3 right) => new(left.x - right.x, left.y - right.y, left.z - right.z);
public static double3 operator *(double3 left, double3 right) => new(left.x * right.x, left.y * right.y, left.z * right.z);
public static double3 operator /(double3 left, double3 right) => new(left.x / right.x, left.y / right.y, left.z / right.z);
public static bool3 operator <(double3 left, double3 right) => new(left.x < right.x, left.y < right.y, left.z < right.z);
public static bool3 operator >(double3 left, double3 right) => new(left.x > right.x, left.y > right.y, left.z > right.z);
public static bool3 operator <=(double3 left, double3 right) => new(left.x <= right.x, left.y <= right.y, left.z <= right.z);
public static bool3 operator >=(double3 left, double3 right) => new(left.x >= right.x, left.y >= right.y, left.z >= right.z);
public static bool3 operator ==(double3 left, double3 right) => new(left.x == right.x, left.y == right.y, left.z == right.z);
public static bool3 operator !=(double3 left, double3 right) => new(left.x != right.x, left.y != right.y, left.z != right.z);
public readonly bool Equals(double3 other) => x.Equals(other.x) && y.Equals(other.y) && z.Equals(other.z);
public override readonly bool Equals(object? obj) => obj is double3 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y, z);
public override readonly string ToString() => FormattableString.Invariant($"double3({x:R}, {y:R}, {z:R})");
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1cbaafd219703ee43ae279aa5cbf407f

View file

@ -0,0 +1,84 @@
using System;
namespace CultMath;
public partial struct float2 : IEquatable<float2>
{
public static readonly float2 zero = new(0.0f, 0.0f);
public static readonly float2 one = new(1.0f, 1.0f);
public float x;
public float y;
public float2(float x, float y)
{
this.x = x;
this.y = y;
}
public float this[int index]
{
readonly get => index switch
{
0 => x,
1 => y,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
set
{
switch (index)
{
case 0: x = value; break;
case 1: y = value; break;
default: throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
public static implicit operator float2(float value) => new(value, value);
public static explicit operator System.Numerics.Vector2(float2 value) => new(value.x, value.y);
public static explicit operator float2(System.Numerics.Vector2 value) => new(value.X, value.Y);
public static float2 operator +(float2 value) => value;
public static float2 operator -(float2 value) => new(-value.x, -value.y);
public static float2 operator +(float2 left, float2 right) => new(left.x + right.x, left.y + right.y);
public static float2 operator +(float2 left, float right) => new(left.x + right, left.y + right);
public static float2 operator +(float left, float2 right) => new(left + right.x, left + right.y);
public static float2 operator -(float2 left, float2 right) => new(left.x - right.x, left.y - right.y);
public static float2 operator -(float2 left, float right) => new(left.x - right, left.y - right);
public static float2 operator -(float left, float2 right) => new(left - right.x, left - right.y);
public static float2 operator *(float2 left, float2 right) => new(left.x * right.x, left.y * right.y);
public static float2 operator *(float2 left, float right) => new(left.x * right, left.y * right);
public static float2 operator *(float left, float2 right) => new(left * right.x, left * right.y);
public static float2 operator /(float2 left, float2 right) => new(left.x / right.x, left.y / right.y);
public static float2 operator /(float2 left, float right) => new(left.x / right, left.y / right);
public static float2 operator /(float left, float2 right) => new(left / right.x, left / right.y);
public static float2 operator %(float2 left, float2 right) => new(left.x % right.x, left.y % right.y);
public static float2 operator %(float2 left, float right) => new(left.x % right, left.y % right);
public static float2 operator %(float left, float2 right) => new(left % right.x, left % right.y);
public static bool2 operator <(float2 left, float2 right) => new(left.x < right.x, left.y < right.y);
public static bool2 operator <(float2 left, float right) => new(left.x < right, left.y < right);
public static bool2 operator <(float left, float2 right) => new(left < right.x, left < right.y);
public static bool2 operator >(float2 left, float2 right) => new(left.x > right.x, left.y > right.y);
public static bool2 operator >(float2 left, float right) => new(left.x > right, left.y > right);
public static bool2 operator >(float left, float2 right) => new(left > right.x, left > right.y);
public static bool2 operator <=(float2 left, float2 right) => new(left.x <= right.x, left.y <= right.y);
public static bool2 operator <=(float2 left, float right) => new(left.x <= right, left.y <= right);
public static bool2 operator <=(float left, float2 right) => new(left <= right.x, left <= right.y);
public static bool2 operator >=(float2 left, float2 right) => new(left.x >= right.x, left.y >= right.y);
public static bool2 operator >=(float2 left, float right) => new(left.x >= right, left.y >= right);
public static bool2 operator >=(float left, float2 right) => new(left >= right.x, left >= right.y);
public static bool2 operator ==(float2 left, float2 right) => new(left.x == right.x, left.y == right.y);
public static bool2 operator ==(float2 left, float right) => new(left.x == right, left.y == right);
public static bool2 operator ==(float left, float2 right) => new(left == right.x, left == right.y);
public static bool2 operator !=(float2 left, float2 right) => new(left.x != right.x, left.y != right.y);
public static bool2 operator !=(float2 left, float right) => new(left.x != right, left.y != right);
public static bool2 operator !=(float left, float2 right) => new(left != right.x, left != right.y);
public readonly bool Equals(float2 other) => x.Equals(other.x) && y.Equals(other.y);
public override readonly bool Equals(object? obj) => obj is float2 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(x, y);
public override readonly string ToString() => FormattableString.Invariant($"float2({x:R}, {y:R})");
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fa066a0aed5494f499d462a2ebc51e50

View file

@ -0,0 +1,67 @@
using System;
using System.Diagnostics.CodeAnalysis;
namespace CultMath;
/// <summary>
/// HLSL float2x2: row-major. Constructors take rows, <c>m[i]</c> is a writable reference to
/// row i (so <c>m[1][0] = s</c> works), and <c>_mRC</c> names row R, column C exactly as HLSL
/// does. Use <see cref="math.mul(float2x2, float2)"/> for products; there is no <c>*</c>
/// matrix product because HLSL's <c>*</c> is component-wise.
/// </summary>
public struct float2x2 : IEquatable<float2x2>
{
public static readonly float2x2 identity = new(1.0f, 0.0f, 0.0f, 1.0f);
public static readonly float2x2 zero = new(0.0f, 0.0f, 0.0f, 0.0f);
private float2 _row0;
private float2 _row1;
public float2x2(float m00, float m01, float m10, float m11)
{
_row0 = new float2(m00, m01);
_row1 = new float2(m10, m11);
}
public float2x2(float2 row0, float2 row1)
{
_row0 = row0;
_row1 = row1;
}
public float _m00 { readonly get => _row0.x; set => _row0.x = value; }
public float _m01 { readonly get => _row0.y; set => _row0.y = value; }
public float _m10 { readonly get => _row1.x; set => _row1.x = value; }
public float _m11 { readonly get => _row1.y; set => _row1.y = value; }
/// <remarks>Writes through a readonly field, <c>in</c> parameter, property, or <c>identity</c> hit a defensive copy and are lost; write to a local and assign it back.</remarks>
[UnscopedRef]
public ref float2 this[int row]
{
get
{
switch (row)
{
case 0: return ref _row0;
case 1: return ref _row1;
default: throw new ArgumentOutOfRangeException(nameof(row));
}
}
}
/// <summary>
/// Counter-clockwise rotation for column vectors: <c>mul(Rotate(a), v)</c> equals
/// <c>math.rotate(v, a)</c>; <c>mul(v, Rotate(a))</c> rotates the other way.
/// </summary>
public static float2x2 Rotate(float radians)
{
var s = MathF.Sin(radians);
var c = MathF.Cos(radians);
return new float2x2(c, -s, s, c);
}
public readonly bool Equals(float2x2 other) => _row0.Equals(other._row0) && _row1.Equals(other._row1);
public override readonly bool Equals(object? obj) => obj is float2x2 other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(_row0, _row1);
public override readonly string ToString() => FormattableString.Invariant($"float2x2({_row0.x:R}, {_row0.y:R}, {_row1.x:R}, {_row1.y:R})");
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c0dcec14c0b7432dbd8ba0bebb0a7c2f

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