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