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