Merge hands/cultlib-unity-release-2.0.0: org.gamecult.cultlib 2.0.0 and org.gamecult.caching.unity 2.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-22 19:03:47 +02:00
commit 42dce3611a
31 changed files with 809 additions and 42 deletions

View file

@ -25,5 +25,5 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0
```

View file

@ -182,7 +182,17 @@ public static class CultDocumentMessagePackSerialization
return buffer.WrittenSpan.ToArray();
}
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload)
/// <summary>
/// Parses a store snapshot whose format stamp is a string, of any version. It refuses a nil or binary stamp
/// (1.0.61 read a nil stamp as v1). A store reading its own file checks the stamp against the formats it reads,
/// before the body is parsed.
/// </summary>
public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) =>
DeserializeSnapshot(payload, _ => { });
// The stamp decides readability and the body never does: requireReadable runs on slot 0 before anything else is parsed.
internal static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload, Action<string> requireReadable)
{
var reader = new MessagePackReader(payload);
var fieldCount = reader.ReadArrayHeader();
@ -190,8 +200,12 @@ public static class CultDocumentMessagePackSerialization
if (fieldCount > 0)
{
// A stamp encoded as binary is not one this runtime wrote or reads; it is refused here, not parsed as damage.
if (reader.NextMessagePackType == MessagePackType.Binary)
throw new NotSupportedException("Store snapshot declares its format version as binary; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + " and " + CultPersistedStoreSnapshot.FormatV2 + " as strings.");
snapshot.FormatVersion = reader.ReadString()
?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + " and " + CultPersistedStoreSnapshot.FormatV2 + ".");
requireReadable(snapshot.FormatVersion);
}
if (fieldCount > 1)
@ -226,12 +240,7 @@ public static class CultDocumentMessagePackSerialization
public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot)
{
var version = snapshot.FormatVersion;
if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) &&
!string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal))
{
throw new NotSupportedException(
$"Store format {DescribeHeader(version)} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1} and {CultPersistedStoreSnapshot.FormatV2}.");
}
RequireReadableSingleFileStamp(version);
// Only a v2 store may hold a variant: a v1 header over a variant record is a store no reader could trust.
if (string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal))
@ -245,6 +254,16 @@ public static class CultDocumentMessagePackSerialization
}
}
internal static void RequireReadableSingleFileStamp(string version)
{
if (!string.Equals(version, CultPersistedStoreSnapshot.FormatV1, StringComparison.Ordinal) &&
!string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal))
{
throw new NotSupportedException(
$"Store format {DescribeHeader(version)} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1} and {CultPersistedStoreSnapshot.FormatV2}.");
}
}
// A store header as a refusal may show it: echoed in the shape cultcache.store.v<digits>, and otherwise described
// only by its length, since the bytes are the store's.
private static string DescribeHeader(string header)
@ -543,7 +562,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp);
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}

View file

@ -448,14 +448,25 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
};
}
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal))
// The stamp is judged before the manifest body is parsed, so a store stamped newer is refused whatever its body.
return CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes, RequireReadableStamp);
}
private void RequireReadableStamp(string stamp)
{
if (CultPersistedStoreSnapshot.FormatNumber(stamp) is int number && number > 4)
{
throw new InvalidOperationException(
$"Directory store {_manifestFile.FullName} is {snapshot.FormatVersion}; only {IndexedFormatVersion} is readable.");
// Every write path parses the manifest under its commit lease before it writes a page or the manifest, so a
// store another process has since stamped newer is refused here, untouched.
throw new NotSupportedException(
$"Directory store {_manifestFile.FullName} is {stamp}, a newer format than this runtime reads ({IndexedFormatVersion}).");
}
return snapshot;
if (!string.Equals(stamp, IndexedFormatVersion, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Directory store {_manifestFile.FullName} is {stamp}; only {IndexedFormatVersion} is readable.");
}
}
private string ContentAddressedRecordPath(CultPersistedRecord metadata)

View file

@ -11,5 +11,6 @@
<PackageReference Include="System.Text.Json" Version="9.0.10" />
<PackageReference Include="MessagePackAnalyzer" Version="3.1.7" PrivateAssets="all" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Mesh" />
</ItemGroup>
</Project>

View file

@ -227,13 +227,14 @@ against what is durably on disk at that moment:
`(key, schemaId, storedAt)`. Any insert, delete or replace fails it.
Record identity for conditions is `(schemaId, storedAt)`, where `schemaId` is
the id the record is persisted under (`CultStoredDocument.PersistedSchemaId`),
the id the record is persisted under (the internal `CultStoredDocument.PersistedSchemaId`),
not the one this cache decodes it as. A value change mints a `storedAt`
strictly later than the record it replaces; writers bump a minted timestamp by
one tick when it is not later. An entry loaded under compatible schema drift
keeps its persisted id until its store rewrites it; a store records each
record its completed write rewrote with the protected instance method
`CacheBackingStore.MarkWritten`, which refuses an entry that is not the one
record its completed write rewrote with the internal instance method
`CacheBackingStore.MarkWritten` (the shipped stores call it; it is not part of
the public API), which refuses an entry that is not the one
the store holds in its own `Entries` at that key. That guards against a store
marking by mistake an entry it never held; a store that writes another copy
into its own `Entries` and then marks it is trusted to have rewritten that

View file

@ -66,6 +66,24 @@ namespace GameCult.Caching
public const string FormatV2 = "cultcache.store.v2";
public string FormatVersion { get; set; } = FormatV1;
/// <summary>
/// The N of a header shaped exactly <c>cultcache.store.v&lt;N&gt;</c> or <c>cultcache.store.v&lt;N&gt;.&lt;suffix&gt;</c>;
/// null for any other header. A header whose N exceeds what a runtime reads is a newer format, which that runtime
/// refuses and must never rewrite.
/// </summary>
internal static int? FormatNumber(string? header)
{
const string prefix = "cultcache.store.v";
if (header == null || !header.StartsWith(prefix, StringComparison.Ordinal))
return null;
var end = prefix.Length;
while (end < header.Length && header[end] >= '0' && header[end] <= '9')
end++;
if (end == prefix.Length || end - prefix.Length > 6 || (end < header.Length && header[end] != '.'))
return null;
return int.Parse(header.Substring(prefix.Length, end - prefix.Length), System.Globalization.CultureInfo.InvariantCulture);
}
public CultSchemaCatalogEntry[] SchemaCatalog { get; set; } = Array.Empty<CultSchemaCatalogEntry>();
public CultPersistedRecord[] Records { get; set; } = Array.Empty<CultPersistedRecord>();
}
@ -394,9 +412,9 @@ namespace GameCult.Caching
/// The schema id this record is durably stored under, as its backing store last read or wrote it. It differs from
/// <see cref="CultDocumentDescriptor.SchemaId"/> only for a record loaded under compatible schema drift that its
/// store has not rewritten since; a store records its rewrite of an entry it holds with <see cref="CacheBackingStore.MarkWritten"/>.
/// Conditional commit compares this id and <see cref="StoredAt"/> with the disk.
/// Conditional commit compares this id and <see cref="StoredAt"/> with the disk. Internal to the shipped stores.
/// </summary>
public string PersistedSchemaId => _persisted.SchemaId;
internal string PersistedSchemaId => _persisted.SchemaId;
// The complete document. For a variant, the resolved view: derived, never written back.
public object Document => _document ??
@ -3333,7 +3351,7 @@ namespace GameCult.Caching
/// </summary>
/// <exception cref="ArgumentException"><paramref name="entry"/> is not the entry this store holds in
/// <see cref="Entries"/> at its key: a store marks only its own rewrite.</exception>
protected void MarkWritten(CultStoredDocument entry)
internal void MarkWritten(CultStoredDocument entry)
{
if (entry == null)
throw new ArgumentNullException(nameof(entry));
@ -3354,6 +3372,7 @@ namespace GameCult.Caching
protected FileInfo FileInfo { get; }
protected abstract byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot);
protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data);
protected abstract byte[] SerializePayload(object document);
protected abstract object DeserializePayload(Type documentType, byte[] payload);
@ -3429,6 +3448,7 @@ namespace GameCult.Caching
{
using (AcquireLock(wait: true))
{
RefuseToRewriteAStoreItCannotRead();
WriteSnapshot(
Entries.Values.Select(entry => ToPersistedRecord(entry, SerializePayload)),
Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()));
@ -3520,14 +3540,37 @@ namespace GameCult.Caching
Judging!(arriving, departing);
}
private CultPersistedStoreSnapshot? ReadSnapshot()
// A plain flush replaces the file without merging it, so under the lock it first reads the disk the way attach and
// commit do: a store whose stamp this runtime does not read is refused and left as it is, and so is a file that
// cannot be read (an I/O or access error). A file whose stamp reads as v1 or v2, or cannot be read at all (empty,
// truncated), is not a newer store, and the flush proceeds as it always has, so it can still heal; that includes a
// v1 file whose body is corrupt or truncated.
private void RefuseToRewriteAStoreItCannotRead()
{
var bytes = ReadFileBytes();
if (bytes == null)
return;
try
{
DeserializeSnapshot(bytes);
}
catch (NotSupportedException)
{
throw;
}
catch (Exception)
{
}
}
private byte[]? ReadFileBytes()
{
FileInfo.Refresh();
if (!FileInfo.Exists)
return null;
try
{
return DeserializeSnapshot(ReadAllBytesShared(FileInfo.FullName));
return ReadAllBytesShared(FileInfo.FullName);
}
catch (FileNotFoundException)
{
@ -3535,6 +3578,12 @@ namespace GameCult.Caching
}
}
private CultPersistedStoreSnapshot? ReadSnapshot()
{
var bytes = ReadFileBytes();
return bytes == null ? null : DeserializeSnapshot(bytes);
}
private void WriteSnapshot(IEnumerable<CultPersistedRecord> records, IEnumerable<CultSchemaCatalogEntry> catalog)
{
var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray();

View file

@ -9,5 +9,6 @@
<PackageReference Include="R3" Version="1.3.0" />
<PackageReference Include="ConcurrentHashSet " Version="1.3.0" />
<InternalsVisibleTo Include="GameCult.Caching.Tests" />
<InternalsVisibleTo Include="GameCult.Caching.MessagePack" />
</ItemGroup>
</Project>

View file

@ -241,7 +241,45 @@ namespace GameCult.Mesh
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
// The write replaces the file, so it first reads what it would replace, under the lock a CultCache single-file
// store takes (<path>.lock): a store this runtime cannot read (a newer format, or damage) or one holding any
// record but this key is refused, never overwritten.
using (AcquireStoreLock(path))
{
RefuseToReplaceAStoreHoldingMore(path, key);
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
}
private static void RefuseToReplaceAStoreHoldingMore(string path, CultRecordKey key)
{
if (!File.Exists(path) || new FileInfo(path).Length == 0)
return;
var existing = ReadSingleFileSnapshot(path);
var other = existing.Records.FirstOrDefault(record =>
record.Variant != null || !string.Equals(record.Key, key.Value, StringComparison.Ordinal));
if (other != null)
throw new InvalidOperationException(
$"CultCache document '{path}' holds record '{other.Key}' besides '{key.Value}'; a CultMesh single-file write " +
"replaces the whole file and would drop it. Write through a CultCache store instead.");
}
private static FileStream AcquireStoreLock(string path)
{
var lockPath = path + ".lock";
var started = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
try
{
return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1);
}
catch (IOException) when (started.Elapsed < TimeSpan.FromSeconds(30))
{
System.Threading.Thread.Sleep(10);
}
}
}
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
@ -250,7 +288,7 @@ namespace GameCult.Mesh
CultPersistedStoreSnapshot snapshot;
try
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes, CultDocumentMessagePackSerialization.RequireReadableSingleFileStamp);
}
catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException)
{

View file

@ -2,6 +2,19 @@
All notable changes to this package are documented in this file.
## [2.0.0]
### Breaking
- Requires `org.gamecult.cultlib` 2.0.0. Unity Package Manager resolves one
cultlib per project, so taking this release moves the project to cultlib 2.0.0.
### Added
- Studio grouping tree: types a document model groups appear under their group,
with create in group.
- Dragging a source record onto a reference field sets the reference.
## [1.4.0]
### Added

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.caching.unity",
"displayName": "GameCult CultCache Unity Tools",
"version": "1.4.0",
"version": "2.0.0",
"unity": "2021.3",
"description": "Unity editor tooling for inspecting and editing CultCache .cc stores.",
"keywords": [
@ -10,7 +10,7 @@
"Data"
],
"dependencies": {
"org.gamecult.cultlib": "1.0.60"
"org.gamecult.cultlib": "2.0.0"
},
"license": "MIT",
"category": "Scripting"

View file

@ -123,6 +123,396 @@ namespace GameCult.Caching.Tests
Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
}
// The release-now-c2a-bumps-format ruling rests on this: a store stamped with a newer format version is refused at
// open, so this runtime cannot rewrite it. The file stays byte-identical through the refusal and through later
// writes to the cache, whose refused store was never attached.
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v9")]
public async Task SingleFileStampedWithANewerFormatIsRefusedAndNeverRewritten(string newerFormat)
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file));
snapshot.FormatVersion = newerFormat;
File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var before = File.ReadAllBytes(file);
using var cache = new CultCache();
var error = Assert.Throws<NotSupportedException>(() =>
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)))!;
Assert.That(error.Message, Does.Contain(newerFormat));
Assert.That(cache.BackingStores, Is.Empty);
await cache.AddAsync(new PreCut2FixtureNote { Text = "written after the refusal" });
await cache.FlushAsync();
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// Two processes: this one attached a v1 store, then a later build rewrote it as v3. A plain flush, a staged write
// and a staged delete must each refuse to downgrade it, leaving the file as the other process wrote it.
[Test]
public async Task SingleFileFlushRefusesAStoreAnotherProcessStampedNewerSinceAttach()
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file));
snapshot.FormatVersion = "cultcache.store.v3";
File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var before = File.ReadAllBytes(file);
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged after the other process wrote v3" });
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
cache.Remove(new CultRecordKey("item:anvil"));
Assert.Throws<NotSupportedException>(() => cache.BackingStores.Single().PushAll());
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// An unreadable stamp is not a newer format: a flush still heals a damaged store.
[Test]
public async Task SingleFileFlushStillHealsAStoreWhoseStampIsUnreadable()
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
File.WriteAllBytes(file, new byte[] { 0xc1, 0xc1 });
await cache.AddAsync(new PreCut2FixtureNote { Text = "heals" });
cache.FlushAllBackingStores();
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)).FormatVersion,
Is.EqualTo("cultcache.store.v1"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static byte[] Raw(params object[] parts)
{
var bytes = new System.Collections.Generic.List<byte>();
foreach (var part in parts)
{
switch (part)
{
case byte single: bytes.Add(single); break;
case string text:
var encoded = System.Text.Encoding.UTF8.GetBytes(text);
bytes.Add(0xd9); bytes.Add((byte)encoded.Length); bytes.AddRange(encoded); break;
case byte[] binary:
bytes.Add(0xc4); bytes.Add((byte)binary.Length); bytes.AddRange(binary); break;
default: throw new ArgumentException(part.GetType().Name);
}
}
return bytes.ToArray();
}
// The format decision comes from the header alone. A stamp this runtime does not read refuses whatever follows it,
// so a body that will not parse can never make a newer store look like damage a flush may heal.
private static System.Collections.Generic.IEnumerable<TestCaseData> NewerStampOverUnreadableBody()
{
byte garbage = 0xc1;
yield return new TestCaseData(Raw((byte)0x93, "cultcache.store.v3", garbage, garbage)).SetName("v3 header over a bad body");
yield return new TestCaseData(Raw((byte)0x93, System.Text.Encoding.UTF8.GetBytes("cultcache.store.v3"), garbage, garbage)).SetName("bin header v3 over a bad body");
yield return new TestCaseData(Raw((byte)0x93, System.Text.Encoding.UTF8.GetBytes("cultcache.store.v9"), garbage, garbage)).SetName("bin header v9 over a bad body");
yield return new TestCaseData(Raw((byte)0x93, "cultcache.store.v3", (byte)0x91, garbage, (byte)0x90)).SetName("v3 header over a bad catalog");
yield return new TestCaseData(Raw((byte)0x92, "cultcache.store.v4", garbage)).SetName("v4 header over garbage");
}
[TestCaseSource(nameof(NewerStampOverUnreadableBody))]
public async Task SingleFileFlushRefusesANewerStampWhateverItsBodyHolds(byte[] swapped)
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
File.WriteAllBytes(file, swapped);
await cache.AddAsync(new PreCut2FixtureNote { Text = "must not overwrite" });
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(File.ReadAllBytes(file), Is.EqualTo(swapped));
Assert.Throws<NotSupportedException>(() => new SingleFileMessagePackBackingStore(file).PullAll());
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public async Task DirectoryWritesRefuseANewerStampWhateverTheManifestBodyHolds()
{
var root = CopyFixture("directory-v4");
try
{
var manifest = Path.Combine(root, "store.cc");
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await cache.PullAllBackingStoresAsync();
var swapped = Raw((byte)0x93, "cultcache.store.v5.directory-content-addressed-pages", (byte)0xc1, (byte)0xc1);
File.WriteAllBytes(manifest, swapped);
await cache.AddAsync(new PreCut2FixtureNote { Text = "must not overwrite" });
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(swapped));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// A file that is read but carries no readable stamp (empty, or cut off) is damage, not a newer store: a flush heals it.
[TestCase(0)]
[TestCase(1)]
[TestCase(10)]
[TestCase(-1)]
public async Task SingleFileFlushHealsAnEmptyOrTruncatedFile(int keep)
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
var whole = File.ReadAllBytes(file);
File.WriteAllBytes(file, whole.Take(keep < 0 ? whole.Length / 2 : keep).ToArray());
await cache.AddAsync(new PreCut2FixtureNote { Text = "heals" });
cache.FlushAllBackingStores();
var healed = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file));
Assert.That(healed.FormatVersion, Is.EqualTo("cultcache.store.v1"));
Assert.That(healed.Records.Length, Is.GreaterThan(0));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// A store subclass that overrides nothing beyond its four abstract members is guarded by the base, not by an opt-in.
private sealed class PlainCustomStore : SingleFileBackingStore
{
public PlainCustomStore(string path) : base(path) { }
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot) =>
CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot);
protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data);
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}
protected override byte[] SerializePayload(object document) =>
CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry);
protected override object DeserializePayload(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
private static void StampHeader(string file, string header)
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file));
snapshot.FormatVersion = header;
File.WriteAllBytes(file, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
[Test]
public async Task CustomSingleFileStoreThatOverridesNothingIsRefusedAtFlushOfANewerStore()
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new PlainCustomStore(file));
await cache.PullAllBackingStoresAsync();
StampHeader(file, "cultcache.store.v3");
var before = File.ReadAllBytes(file);
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" });
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// Attach, commit and flush share one rule for which formats are readable: a header the reader refuses at attach is
// refused at flush, whatever its spelling.
[TestCase("cultcache.store.v3-ids")]
[TestCase("cultcache.store.v1000000")]
[TestCase("cultcache.v3")]
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v2")]
[TestCase("cultcache.store.v1")]
public async Task FlushRefusesExactlyTheHeadersAttachRefuses(string header)
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
await cache.PullAllBackingStoresAsync();
StampHeader(file, header);
var before = File.ReadAllBytes(file);
bool attachRefuses;
using (var fresh = new CultCache())
{
try { fresh.AddBackingStore(new SingleFileMessagePackBackingStore(file)); attachRefuses = false; }
catch (NotSupportedException) { attachRefuses = true; }
}
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" });
var flushRefuses = false;
try { cache.FlushAllBackingStores(); }
catch (NotSupportedException) { flushRefuses = true; }
Assert.That(flushRefuses, Is.EqualTo(attachRefuses), header);
if (attachRefuses) Assert.That(File.ReadAllBytes(file), Is.EqualTo(before), header);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// The refusal comes from the read, not from a failed write onto the held file: the store never reaches serialising.
private sealed class WriteCountingStore : SingleFileMessagePackBackingStore
{
public int Serialisations;
public WriteCountingStore(string path) : base(path) { }
protected override byte[] SerializeSnapshot(CultPersistedStoreSnapshot snapshot)
{
Serialisations++;
return base.SerializeSnapshot(snapshot);
}
}
// The public parser judges no stamp, as in 1.0.61: a v4 directory manifest and a v3 header both parse.
[Test]
public void PublicParserReadsAnyStampAsEveryReleaseHas()
{
var root = CopyFixture("directory-v4");
try
{
var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(root, "store.cc")));
Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
var v3 = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v3", SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(), Records = Array.Empty<CultPersistedRecord>() };
var parsed = CultDocumentMessagePackSerialization.DeserializeSnapshot(CultDocumentMessagePackSerialization.SerializeSnapshot(v3));
Assert.That(parsed.FormatVersion, Is.EqualTo("cultcache.store.v3"));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
// A read that fails on I/O is not an unreadable stamp to heal over: the flush refuses and the file is untouched.
[Test]
public async Task SingleFileFlushRefusesWhenReadingTheDiskFailsOnIO()
{
var root = CopyFixture("single-file-v1");
try
{
var file = Path.Combine(root, "store.msgpack");
using var cache = new CultCache();
var store = new WriteCountingStore(file);
cache.AddBackingStore(store);
await cache.PullAllBackingStoresAsync();
var before = File.ReadAllBytes(file);
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged" });
store.Serialisations = 0;
using (new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
Assert.Throws<IOException>(() => cache.FlushAllBackingStores());
}
Assert.That(store.Serialisations, Is.Zero, "the refusal is the read failing, not the write onto the held file failing");
Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[TestCase("cultcache.store.v5.directory-content-addressed-pages")]
[TestCase("cultcache.store.v9")]
public async Task DirectoryWritesRefuseAStoreAnotherProcessStampedNewerSinceAttach(string newer)
{
var root = CopyFixture("directory-v4");
try
{
var manifest = Path.Combine(root, "store.cc");
using var cache = new CultCache();
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
await cache.PullAllBackingStoresAsync();
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest));
snapshot.FormatVersion = newer;
File.WriteAllBytes(manifest, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
Func<System.Collections.Generic.Dictionary<string, byte[]>> files = () => Directory
.GetFiles(root, "*", SearchOption.AllDirectories)
.Where(path => !path.EndsWith(".lock", StringComparison.Ordinal))
.ToDictionary(path => path, File.ReadAllBytes);
var before = files();
await cache.AddAsync(new PreCut2FixtureNote { Text = "staged after the other process wrote the newer format" });
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(files().Keys, Is.EquivalentTo(before.Keys));
foreach (var pair in before) Assert.That(File.ReadAllBytes(pair.Key), Is.EqualTo(pair.Value), pair.Key);
cache.Remove(new CultRecordKey("item:anvil"));
Assert.Throws<NotSupportedException>(() => cache.FlushAllBackingStores());
Assert.That(files().Keys, Is.EquivalentTo(before.Keys));
foreach (var pair in before) Assert.That(File.ReadAllBytes(pair.Key), Is.EqualTo(pair.Value), pair.Key);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
[Test]
public void SingleFileRefusesExtraRecordSlotNamingTheRecord()
{

View file

@ -0,0 +1,119 @@
using System;
using System.IO;
using FluentAssertions;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using NUnit.Framework;
#nullable enable
namespace GameCult.Mesh.Tests;
// A CultMesh single-file write replaces the whole file, so it must read what it replaces first. A store stamped with a
// newer format version, one it cannot read, or one holding a record the write would drop is refused and left byte-identical.
public sealed class CultMeshSingleFileWriteGuardTests
{
private static readonly CultMeshSingleFileDocumentSchema Schema = new("sha256:guard", "guard.item", "1");
private string _root = null!;
private string _path = null!;
[SetUp]
public void SetUp()
{
_root = Path.Combine(Path.GetTempPath(), "cultmesh-write-guard", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_path = Path.Combine(_root, "store.cc");
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_root)) Directory.Delete(_root, true);
}
private void Write(string key, byte payload) =>
CultMesh.WriteSingleFileDocumentPayload(_path, new CultRecordKey(key), Schema, null, new[] { payload });
[TestCase("cultcache.store.v3")]
[TestCase("cultcache.store.v9")]
public void WriteRefusesAStoreStampedWithANewerFormat(string newer)
{
Write("item:a", 1);
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
snapshot.FormatVersion = newer;
File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var before = File.ReadAllBytes(_path);
var error = Assert.Throws<NotSupportedException>(() => Write("item:a", 2))!;
error.Message.Should().Contain(newer);
File.ReadAllBytes(_path).Should().Equal(before);
}
[Test]
public void WriteRefusesAFileItCannotRead()
{
var garbage = new byte[] { 0xc1, 0xc1, 0xc1, 0xc1 };
File.WriteAllBytes(_path, garbage);
Assert.Catch(() => Write("item:a", 1));
File.ReadAllBytes(_path).Should().Equal(garbage);
}
[Test]
public void WriteRefusesToDropAnotherRecord()
{
Write("item:a", 1);
var before = File.ReadAllBytes(_path);
var error = Assert.Throws<InvalidOperationException>(() => Write("item:b", 2))!;
error.Message.Should().Contain("item:a");
File.ReadAllBytes(_path).Should().Equal(before);
}
[Test]
public void WriteReplacesTheSameRecordAndCreatesAMissingFile()
{
Write("item:a", 1);
Write("item:a", 2);
CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2);
}
// The write takes the same <path>.lock a CultCache single-file store takes: while another holder has it, the write waits.
[Test]
public void WriteWaitsForTheStoreLockAnotherWriterHolds()
{
Write("item:a", 1);
var write = default(System.Threading.Tasks.Task);
using (new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
write = System.Threading.Tasks.Task.Run(() => Write("item:a", 2));
write.Wait(TimeSpan.FromMilliseconds(750)).Should().BeFalse("the lock is held, so the write must wait");
CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(1);
}
write.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue();
CultMesh.ReadSingleFileDocumentPayload(_path, new CultRecordKey("item:a"), Schema.SchemaId).Should().Equal(2);
}
// A variant at the very key being written is still not this write's to replace: a plain record would flatten it.
[Test]
public void WriteRefusesToReplaceAVariantAtTheSameKey()
{
Write("item:a", 1);
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
snapshot.FormatVersion = CultPersistedStoreSnapshot.FormatV2;
snapshot.Records[0].Payload = Array.Empty<byte>();
snapshot.Records[0].Variant = new CultVariantDelta("item:base", Array.Empty<CultVariantOverride>());
File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var before = File.ReadAllBytes(_path);
Assert.Throws<InvalidOperationException>(() => Write("item:a", 2));
File.ReadAllBytes(_path).Should().Equal(before);
}
}

View file

@ -2,7 +2,7 @@
All notable changes to this package are documented in this file.
## [Unreleased]
## [2.0.0] - 2026-10-22
### Breaking
@ -24,22 +24,122 @@ All notable changes to this package are documented in this file.
open or pull must catch `InvalidDataException`. 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`: the `CultCache(CultDocumentRegistry?)` constructor is now
`CultCache(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 an `out` report of the schema drift it shed. The
`CultInspectorModel(registry, Func serialize, Func deserialize)` constructor is
now `CultInspectorModel(registry, CultCodec codec)`.
- `GameCult.Mesh`: `CultMeshPeerSnapshotDocumentOptions.SchemaIds`,
`CultMeshSnapshotRequestOptions.SchemaIds` and `.RecordKeys` are removed; set
`Selection` (a `CultNetSelection`) instead. `CultMeshHotBodySubscription.SchemaIds`
and `.RecordKeys` are removed; read `Selection`.
- `GameCult.Networking`: `CultNetDatabaseSubscriptionServer` no longer takes the
`projectRecord` constructor argument; a subscription is narrowed by its
selection and `authorizeRecord`.
- `InitialSequence` is `uint?` instead of `uint` on `CultMeshRudpSocketOptions`,
`RudpCultNetSchemaServerOptions`, `CultNetRudpSessionOptions`,
`CultNetRudpSocketTransportOptions` and `CultNetRudpSocketTransportServerOptions`.
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 `Owner` beside a reference member named `Owner`)
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.v1` and `cultcache.store.v2` are 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: `SendSchema` throws the `SocketException` for 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 (`PeerDisconnected` carries 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;
`FlushReliable` throws for writes an ended session forgot; the listener replaces a
peer on a Connect only when its connect payload differs (reporting
`PeerDisconnected` for the old one) where it used to replace it on every Connect;
server mode is claimed only by a Connect; an `InitialSequence` of `uint.MaxValue`
is rejected; malformed, foreign-connection and refused packets are dropped and
counted in `Stats.PacketsDropped` instead of throwing out of `TryReceiveOnce`.
- `CultNetDatabase.PutAsync` and `DeleteAsync` for a schema no shard owns throw
`CultNetUnownedSchemaException` (an `InvalidOperationException` naming 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.BindHost` is 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 bind `0.0.0.0` for every client, so an
IPv6 endpoint could not be reached); a server still binds `0.0.0.0`. An explicit
`BindHost` wins 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.v2` and 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 stays `cultcache.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 throws
`NotSupportedException` instead 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 stamped
`cultcache.store.v<N>` with N > 4 (it threw `InvalidOperationException`). 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 public `DeserializeSnapshot(byte[])` still parses any string stamp, but refuses a nil
or binary stamp (1.0.61 read a nil stamp as v1).
- `CultMesh.WriteSingleFileDocument` and `WriteSingleFileDocumentPayload` read 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 a `CultCache`.
### Added
- `CultStoredDocument.PersistedSchemaId`: the schema id a record is durably
stored under, as its backing store last read or wrote it. It differs from
`Descriptor.SchemaId` only for a record loaded under compatible schema drift
that its store has not rewritten since.
- `CacheBackingStore.MarkWritten(CultStoredDocument)` (protected instance
method): a backing store records that its completed durable write stored a
record under its descriptor, so `PersistedSchemaId` follows the rewrite. It
throws `ArgumentException` for an entry that is not the one the store holds
in its own `Entries`. The single-file and directory stores mark every record
they rewrite; a custom store calls it for each record its own write rewrote.
- 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, and `Flatten` replaces a
variant with a plain record.
- CultNet selection v1: one `CultNetSelection` shape 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.
- `CultMath` value types have canonical MessagePack formatters and `System.Text.Json`
converters, including `CultPhasor`, `CultSiteCells`, `SpanNoise`, `SpanPath`,
`SpanStage` and the `ExprGraph` expression graph resolver.
- CultNet publishes every committed cache change once, in `Sequence` order, from the
cache journal; `CultNetDatabase.Dispose` publishes what a writer logged and had
not yet published.
- `CultCache` takes an optional `CultCodec`, and isolates every observer so a
throwing one cannot stop the others.
### Changed
- Requires `org.gamecult.cultmath` exactly 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`, an `Expect` captured before fails until this cache re-reads the
@ -47,11 +147,24 @@ All notable changes to this package are documented in this file.
### Fixed
- The native QUIC plugins' import settings enable `Standalone: Win64` and disable the
32-bit target (the 1.0.61 fix, now on main).
- `Expect` and `ExpectUnchanged` hold 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.dll`
and `msquic.dll`) are enabled for 64-bit Windows players (`Standalone: Win64`).
Their import settings named only `Standalone: 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

View file

@ -11,19 +11,19 @@ and OpenSSL license notices are included under `Third Party Notices`.
Build it with `scripts/build-unity-package.ps1`. Do not edit generated plugin
assemblies in the package output.
Install version `1.0.57` through Unity Package Manager with:
Install version `2.0.0` through Unity Package Manager with:
```text
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0
```
The package depends on `org.gamecult.cultmath` (0.3.0 or later) and does not
The package depends on `org.gamecult.cultmath` (exactly 0.5.0) and does not
carry its own `CultMath.dll`. Unity Package Manager resolves git packages only
from the project manifest, so add CultMath beside CultLib in
`Packages/manifest.json`:
```text
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.3.0"
"org.gamecult.cultmath": "https://github.com/GameCult/CultLib.git?path=/packages/cultmath/unity/org.gamecult.cultmath#cultmath-unity-v0.5.0"
```
CultMesh chooses mapped memory for reachable same-machine bodies and advertised

View file

@ -25,6 +25,12 @@ PluginImporter:
DefaultValueInitialized: true
- first:
Standalone: Win
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win64
second:
enabled: 1
settings:

View file

@ -25,6 +25,12 @@ PluginImporter:
DefaultValueInitialized: true
- first:
Standalone: Win
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: Win64
second:
enabled: 1
settings:

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.cultlib",
"displayName": "CultLib",
"version": "1.0.60",
"version": "2.0.0",
"unity": "2021.3",
"dependencies": {
"org.gamecult.cultmath": "0.5.0"