CultDocumentRegistry.Owns replaces Declares as the one answer to whether a writer may write, relabel or remove a record under a schema id: the id is declared, or the type that reads it reads it whole (equal content hash, or compatible shapes with no persisted slot ignored). Staging asks the declared half or CultStoredDocument.StoredSchemaReadWhole; the apply halves (single file, directory, cacheless CultMesh) ask Owns over the durable catalog, once per distinct id. A record read only by dropping slots stays read-only until its id is declared compatible. Not built or run at this commit: verified on Yggdrasil at the cut's head. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
21 KiB
21 KiB
Changelog
All notable changes to this package are documented in this file.
[Unreleased]
Releases as 3.0.0 (major).
Breaking (major)
- A single-file store flush refuses a file it cannot read (empty, truncated, an unreadable stamp or body) with
CultStoreUnreadableExceptionand leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A single-file store reads v1, v2 and v3 stamps and refuses v4 and later by name; a directory store reads v4 and v5 and refuses v6 and later by name. Each judges the stamp before the body, so an unreadable body never makes a newer store look like damage.
- A store is written in the format its content needs, and CultLib 2.0 refuses the newer ones by name. A single-file store holding a document variant is written as
cultcache.store.v2, one holding an element id ascultcache.store.v3; a directory store holding an element id is written ascultcache.store.v5(v4otherwise). A store is marked only when a record actually holds an id or a variant. Upgrade readers before writers: a 2.0 reader that opens such a store is refused, not misread. - Writes are write sets. A flush or commit replaces the records it staged and copies every other record forward as stored, under the store's lock, instead of rewriting the whole store from the cache's memory. A record the cache does not know is kept, and an untouched record keeps a dropped member's slot through every write (never reuse a dropped slot; see the schema-compatibility contract).
- A write or removal at a key whose stored record the writer does not own is refused with
CultSchemaConflictException, and the file is left as it was. A writer owns a record whose schema id it declares (owns or lists compatible) and a record its type reads whole under compatible drift: that record is writable and is restamped under the type's own id on write, as in 2.0.1. A record a type reads only by dropping persisted slots stays read-only until its id is declared compatible, which accepts the loss (this differs from 2.0.1, which shed the slots silently). A cache owns through its registry; a cacheless CultMesh write owns through the catalog entry it writes. - Registration refuses an owned object-list element type that has no
[CultElementId]member (a public string with[Key]), naming the type and the list. Element ids let a write set merge a list by identity; a list of record references or plain values is a whole value and is not affected. - The abstract and protected surface for store kinds outside this library changed. On
SingleFileBackingStore,SerializeSnapshotandDeserializeSnapshotare replaced by the abstractSerializeStore,SerializeRecord,SerializeCatalogEntryandReadStore, and the abstractSerializePayload(object)becomesSerializePayload(object document, Type documentType): a store encodes a document as the type it is given. OnCacheBackingStore,Judgingis removed,ToStoredDocumenttakes the record's version (record, version, catalog, deserialize, out report), and these protected members are new for a store kind to implement or call:HeaderFor,HeaderForWrite,Foreign,SetForeignRecords,Undeclared,RefuseUndeclared,RefuseUndeclaredOnDisk,RefuseRemovalOfUndeclared,Refuse,SameStoredIdentity,VersionOf,StoredAtOver,Overwrites,StagedChecks,StageChecksandJudgeChecks.MarkFlushSucceededis unchanged.CacheBackingStore.ForeignRecordsis new and public: the records a store holds under a schema id its cache does not declare. CultCommitRequest.Expectedis nowIReadOnlyList<(CultRecordKey Key, string? Version)>(it was a key with a schema id and a stored-at),CultCommitRequest.Checksis new, andConditionsHoldtakes(IReadOnlyDictionary<string, string> durable, IEnumerable<CultStoredDocument> observed, IEnumerable<CultForeignRecord> foreign)in place of(IReadOnlyCollection<CultPersistedRecord>, IEnumerable<CultStoredDocument>). A store kind outside the library lists the record versions it holds and passes its foreign records.GameCult.Mesh:AsSchemaAlias<TAlias>()is removed from the interfacesICultMeshDocumentHandleandICultMeshCollectionHandleand from the classesCultMeshDocumentHandleandCultMeshCollectionHandle. Read the handle by the schema you mean.- The element types of owned lists gain a public string
Idmember with[CultElementId], so their lists merge by identity: inGameCult.Mesh,CultMeshBodyDescriptor,CultMeshCdnChunkRef,CultMeshDiscoveryCandidateDocument,CultMeshEntityPrefabComponent,CultMeshEntityPrefabAssetRefandCultMeshEntityPrefabNode; inGameCult.Networking,CultWitnessContractPin,CultWitnessArtifactEntryandCultWitnessTimingEntry; inGameCult.Geometry,CultGeometryDomainNode,CultGeometryFeatureClaimandCultGeometryContributionRow. Code that matched those elements positionally by shape must set or readId. CultNetDocumentRegistry.Registerrefuses a binding that claims a document type or a schema id another binding already holds, withCultSchemaConflictException; binding a type to the schema id it already holds still replaces that binding.InternalsVisibleTo GameCult.Meshis removed fromGameCult.Caching: CultMesh's single-file document helpers use the publicReadStoreSnapshot,WriteDocument,WriteRecordandCultCodec.DecodeDocumentbelow.
Added
- A cacheless
WriteRecordorWriteDocument, and an attach, hold the store's gate while they run, and user code they run (a document's property getters, a constructor while a copied record is read) now throwsInvalidOperationExceptionif it enters a cache it does not hold or a second store; it used to wait forever against an attach of the same store. A store call that queued on an attach that then failed runs under the gate that owns the store, not beside a cacheless call or a second cache's attach. An attach that fails takes its registry back, so aPullAllon the store afterwards is refused as unattached instead of loading records the next cache never sees. A cachelessWriteRecordorWriteDocumentmade while the same thread already holds that store from user code another cacheless write of the store runs, or from a journal an attach's pull runs (then "The store is attached to a cache, write through the cache.", as after the attach) throwsInvalidOperationException; it used to write the file behind the attaching cache and take that cache's registry, so the cache could never flush or pull the store again. A journal that calls its cache's store (Push,Delete,PushAll,CommitBatch,PullAll,ReadStoreSnapshot,JudgeChecks) while that cache'sAddBackingStoreruns the journal now throws "A journal entered the cache it journals", as it does after the attach; it used to stage or write records the cache never admitted, so the cache and the file disagreed. SingleFileBackingStore.ReadStoreSnapshot()reads a store file once, under the store's in-process gate, with the reader a cache's store opens with, and returns the durable snapshot or null when nothing is at the path.SingleFileBackingStore.WriteRecord(record, catalogEntry, holdsIds, registry)writes one record without a cache: under the store's lock every other record and catalog entry is copied as stored, the file is replaced atomically, and the header is decided as a flush decides it. It refuses on a store attached to a cache.SingleFileBackingStore.WriteDocument<T>(key, document, storedAt, registry)writes one typed document without a cache, as a cache write does: it plans element ids, encodes the document asT(the type its schema id and ids are taken from, also for a derived instance) and marks the store when it holds an id; a failed write gives the minted ids back. It refuses on a store attached to a cache. TheregistryofWriteRecordandWriteDocumentserves that call only; a store keeps none.CultCodec.DecodeDocument<T>(key, payload)decodes a stored payload as a cache load does (CultCacheMessagePack.CreateCodec(registry)makes the codec). It reads no file and holds no state.CultElementIdsstays internal.CultPersistedStoreSnapshot.FormatV3names the element-id store header.CultDocumentAttribute.CompatibleSchemaIdsandCultDocumentDescriptor.CompatibleSchemaIds: the schema ids besides its own a document type claims, so a record stored under one of them loads as that type.CultStoredDocument.StoredSchemaId(the schema id the record carries in its store, which may be an older one) andCultStoredDocument.HoldsIds.CultCache.MintElementIds()rewrites, once, the records of a store written before element ids whose ids exist only in memory, and returns how many it rewrote.CultElementIdAttributedeclares an element id (derived from a named member when given),CultForeignRecordis a record a store carries untouched because no registered type reads it, andCultElementIdException,CultSchemaConflictException,CultStoreUnreadableExceptionandCultWriteConflictExceptionare the refusals named above.- A store written by 1.0.x with a record-reference list in a slot still opens (from 2.0.1).
[2.0.1] - 2026-10-22
Fixed
- A store written by 1.0.x with a list of record references (
CultRecordRef<T>[]or anyIEnumerable<CultRecordRef<T>>slot) opens again. 2.0.0 classified such a slot as a reference toT, 1.0.x had persisted it as a plain value, and the schema comparison refused the store with "changed reference semantics" and "changed target schema". The payload is the same array of keys, so gaining the reference classification on an otherwise identical slot is now compatible drift (migration warninggained_reference_semantics). A reference that is lost or retargeted, and any change of type or cardinality, still refuses.
[2.0.0] - 2026-10-22
Breaking
CultMathJsonwritesCultPhasoras{"cosine":..,"sine":..}and still reads the oldcos/sinkeys. A reader from before this change does not know the new keys: with default options it skips them and decodes zeros, and withUnmappedMemberHandling.Disallowit throws. Upgrade readers before writers. MessagePack bytes are unchanged.- CultCache refuses an unknown union arm on read and an unnamed union subtype on
write. A payload whose arm key the union does not declare used to decode to
null(and the next save wrote the loss back); a value whose type the union does not name used to be written asnil. Both now throw, naming the union and the key or type, and a store pull names the record key and schema id. Known arms encode to the same bytes as before. - A payload that fails to decode during a store pull (single-file or directory)
now throws
InvalidDataExceptionnaming the record key and schema id, with the original exception (for exampleMessagePackSerializationException) as itsInnerException. Callers that caught the codec's exception type around a store open or pull must catchInvalidDataException. An arm key outside Int32 or not an integer is refused with the same union-naming refusal, and a variant override holding an unknown arm names the schema id. - Removed or retyped public API, each with its replacement (measured against 1.0.61):
GameCult.Caching: theCultCache(CultDocumentRegistry?)constructor is nowCultCache(CultDocumentRegistry? registry = null, CultCodec? codec = null); code that bound the old signature by reflection or a method group must rebind.CacheBackingStore.ToStoredDocument(CultPersistedRecord, IReadOnlyCollection<..>, ..)gains anoutreport of the schema drift it shed. TheCultInspectorModel(registry, Func serialize, Func deserialize)constructor is nowCultInspectorModel(registry, CultCodec codec).GameCult.Mesh:CultMeshPeerSnapshotDocumentOptions.SchemaIds,CultMeshSnapshotRequestOptions.SchemaIdsand.RecordKeysare removed; setSelection(aCultNetSelection) instead.CultMeshHotBodySubscription.SchemaIdsand.RecordKeysare removed; readSelection.GameCult.Networking:CultNetDatabaseSubscriptionServerno longer takes theprojectRecordconstructor argument; a subscription is narrowed by its selection andauthorizeRecord.InitialSequenceisuint?instead ofuintonCultMeshRudpSocketOptions,RudpCultNetSchemaServerOptions,CultNetRudpSessionOptions,CultNetRudpSocketTransportOptionsandCultNetRudpSocketTransportServerOptions. Unset, a session draws a random initial sequence in [1, 2^31) (the old defaults were 1 and 100); set a value to keep a fixed one.
- A data member aliased the same name as a reference member in one document type
(for example a member aliased
Ownerbeside a reference member namedOwner) used to register and is now refused at registration, naming the clash. - A store header other than one this runtime knows, and a record with more slots
than its header allows, are refused by name on read instead of being skipped or
read as
cultcache.store.v1. A reader from before this change silently dropped the extra slot (for example a variant) and the next save wrote the loss back.cultcache.store.v1andcultcache.store.v2are the formats this release reads; a store stamped with a newer one is refused, so this release never rewrites it. - CultCache payload nesting is bounded at 256 on read and on write (it was 500), and a
union arm slot
[k, nil]is refused. A store holding a record nested 257 to 500 deep, which 1.0.61 read and wrote, no longer opens, and a value nested that deep can no longer be saved; flatten such a record before upgrading. - RUDP:
SendSchemathrows theSocketExceptionfor a send that can never succeed as built (MessageSize,InvalidArgument,AddressFamilyNotSupported), queues nothing, and a permanent failure after an earlier fragment left ends the session. It used to count every send failure as a lost datagram and return. Inside a poll such a failure ends only that peer's session (PeerDisconnectedcarries a reason naming the error) and the poll is not failed. - RUDP sessions end where they used to carry on: a peer whose message cannot be
decoded or handled has its session ended with a goodbye, instead of throwing out
of
PollAvailableAsync/PollOnceAsync; a session that refuses a packet ends;FlushReliablethrows for writes an ended session forgot; the listener replaces a peer on a Connect only when its connect payload differs (reportingPeerDisconnectedfor the old one) where it used to replace it on every Connect; server mode is claimed only by a Connect; anInitialSequenceofuint.MaxValueis rejected; malformed, foreign-connection and refused packets are dropped and counted inStats.PacketsDroppedinstead of throwing out ofTryReceiveOnce. CultNetDatabase.PutAsyncandDeleteAsyncfor a schema no shard owns throwCultNetUnownedSchemaException(anInvalidOperationExceptionnaming the schema) before the cache is touched. They used to cache the write and never log it. The fallback shard is gone: a database with shards that do not cover a schema serves and writes only what its shards own.- RUDP:
CultMeshRudpSocketOptions.BindHostis nullable and unset by default. A client now binds loopback for a loopback endpoint and the unspecified address of the endpoint's family otherwise (it used to bind0.0.0.0for every client, so an IPv6 endpoint could not be reached); a server still binds0.0.0.0. An explicitBindHostwins for both. - RUDP wire behaviour changed in step across the C#, Rust, TypeScript and Python runtimes: one received-through watermark per session owns ordered delivery; a Connect repeats only when it carries the sequence that opened the generation, and reliable data before the handshake is neither acknowledged nor delivered; the sender admits a packet only within 1,023 sequences and 4 MiB above the lowest unacknowledged one. A peer on 1.0.x interoperates only for traffic that stays inside those bounds.
- Variants (record-level wire and store format): a store holding at least one
variant declares
cultcache.store.v2and its variant records carry a fifth slot. A reader from before this change refuses the store (unknown header) rather than reading it. A store with no variant stayscultcache.store.v1, byte-identical to 1.0.x. CultMesh single-file reads, the directory store and CultNet refuse a variant record by name; only the C# CultCache resolves variants. - A plain flush (
SingleFileBackingStore.PushAll) reads the file under the lock before it rewrites it, by the same rule attach and conditional commit use (the format stamp in the header decides, before the body is parsed), and throwsNotSupportedExceptioninstead of writing when the store is one this runtime refuses to read: stamped by another process with a newer format, or with a stamp the reader refuses. A read that fails with an I/O or access error also refuses. It used to overwrite the file from this cache's older view. A directory store throws the same exception, from the manifest parse every write already ran, for a manifest stampedcultcache.store.v<N>with N > 4 (it threwInvalidOperationException). A flush rewrites any file whose stamp it can read as v1 or v2, or cannot read at all (empty, truncated, not a snapshot), which includes a v1 file whose body is corrupt or truncated. It refuses any other string stamp, a nil or binary stamp, and an I/O failure on the read. The publicDeserializeSnapshot(byte[])still parses any string stamp, but refuses a nil or binary stamp (1.0.61 read a nil stamp as v1). CultMesh.WriteSingleFileDocumentandWriteSingleFileDocumentPayloadread the file they replace, under the store's<path>.lock, and refuse (without touching it) a file this runtime cannot read, a store stamped with a newer format version, or one holding any record besides the key being written. They used to replace the file whole. Write to a store that holds several records through aCultCache.
Added
- Variants: a record that stores only its difference from a base record, resolved in
memory when the cache admits it (
cultcache.store.v2; see the persistence-format contract). A plain write at a variant's key is refused, andFlattenreplaces a variant with a plain record. - CultNet selection v1: one
CultNetSelectionshape and evaluator (schemas, keys, field predicates, cites, cursors) used by CultMesh snapshot requests, the database server and subscriptions, with typed refusals and row identity of(schemaId, recordKey). v0 filters are lowered onto it with their pre-existing answers. - Record grouping: a document type declares its inspector group, and the inspector model exposes the group tree, create-in-group and reference labels.
CultMathvalue types have canonical MessagePack formatters andSystem.Text.Jsonconverters, includingCultPhasor,CultSiteCells,SpanNoise,SpanPath,SpanStageand theExprGraphexpression graph resolver.- CultNet publishes every committed cache change once, in
Sequenceorder, from the cache journal;CultNetDatabase.Disposepublishes what a writer logged and had not yet published. CultCachetakes an optionalCultCodec, and isolates every observer so a throwing one cannot stop the others.
Changed
- Requires
org.gamecult.cultmathexactly 0.5.0. - After another writer's whole-file flush or unconditional commit rewrites a
drifted record under a schema id this cache did not read it under, keeping its
storedAt, anExpectcaptured before fails until this cache re-reads the store (PullAll), as for any other lost race.
Fixed
- The native QUIC plugins' import settings enable
Standalone: Win64and disable the 32-bit target (the 1.0.61 fix, now on main). ExpectandExpectUnchangedhold on an unchanged record loaded under compatible schema drift (written under one version of its type, read by a cache that knows another). Before, they always failed: conditional commit compared the record's persisted schema id with the id this cache decodes it as.
[1.0.61] - 2026-10-03
Fixed
- The native QUIC plugins under
Runtime/Plugins/x86_64(gamecult_mesh_quic_native.dllandmsquic.dll) are enabled for 64-bit Windows players (Standalone: Win64). Their import settings named onlyStandalone: Win, the 32-bit target, so a Win64 player build left both DLLs out and the native QUIC bridge was unavailable at runtime. They are now disabled for 32-bit Windows, which they cannot load.
[1.0.60]
Changed
- Native QUIC bridge: Schannel replaced by OpenSSL MsQuic 2.5.9, statically
linked, first shipping in this package with this release.
msquic.dllgrew from 536,928 to 4,181,856 bytes and the bridge from 40,448 to 302,080 bytes as a result of the static CRT and the statically linked OpenSSL build. The bridge keeps its v1 exports on a v2 event-queue runtime. An OpenSSL notice was added underThird Party Notices.
Breaking
CultInspectorAssetPathis removed. Inspector annotations that stored an asset by path now useCultInspectorAssetGuid, whose value is aCultAssetGuidKey: the bare Addressables GUID for a main asset, orguid[subAssetName]for a picked sub-asset.