diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md
index ed8746bb..301e54eb 100644
--- a/docs/nuget-packaging.md
+++ b/docs/nuget-packaging.md
@@ -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
```
diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index df7302de..9db26a97 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -182,7 +182,17 @@ public static class CultDocumentMessagePackSerialization
return buffer.WrittenSpan.ToArray();
}
- public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload)
+ ///
+ /// 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.
+ ///
+ 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 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, 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;
}
diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index 61e65208..11a84591 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -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)
diff --git a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj
index c2588e24..b81dcf92 100644
--- a/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj
+++ b/src/GameCult.Caching.MessagePack/GameCult.Caching.MessagePack.csproj
@@ -11,5 +11,6 @@
+
diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md
index 93bcd5f5..e23d491f 100644
--- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md
+++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md
@@ -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
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index bc95ace9..53a7d18e 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -66,6 +66,24 @@ namespace GameCult.Caching
public const string FormatV2 = "cultcache.store.v2";
public string FormatVersion { get; set; } = FormatV1;
+
+ ///
+ /// The N of a header shaped exactly cultcache.store.v<N> or cultcache.store.v<N>.<suffix>;
+ /// 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.
+ ///
+ 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();
public CultPersistedRecord[] Records { get; set; } = Array.Empty();
}
@@ -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
/// 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 .
- /// Conditional commit compares this id and with the disk.
+ /// Conditional commit compares this id and with the disk. Internal to the shipped stores.
///
- 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
///
/// is not the entry this store holds in
/// at its key: a store marks only its own rewrite.
- 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 records, IEnumerable catalog)
{
var ordered = records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray();
diff --git a/src/GameCult.Caching/GameCult.Caching.csproj b/src/GameCult.Caching/GameCult.Caching.csproj
index 7bc41ccc..c3ec80ae 100644
--- a/src/GameCult.Caching/GameCult.Caching.csproj
+++ b/src/GameCult.Caching/GameCult.Caching.csproj
@@ -9,5 +9,6 @@
+
\ No newline at end of file
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 700b7a50..17e0343d 100644
--- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
+++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
@@ -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 (.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)
{
diff --git a/src/GameCult.Unity/Assets/Caching/CHANGELOG.md b/src/GameCult.Unity/Assets/Caching/CHANGELOG.md
index a5a0182a..4f533ecd 100644
--- a/src/GameCult.Unity/Assets/Caching/CHANGELOG.md
+++ b/src/GameCult.Unity/Assets/Caching/CHANGELOG.md
@@ -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
diff --git a/src/GameCult.Unity/Assets/Caching/package.json b/src/GameCult.Unity/Assets/Caching/package.json
index dbd0815c..8c6fa91e 100644
--- a/src/GameCult.Unity/Assets/Caching/package.json
+++ b/src/GameCult.Unity/Assets/Caching/package.json
@@ -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"
diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
index e9d8797d..5e862724 100644
--- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
+++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
@@ -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(() =>
+ 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(() => cache.FlushAllBackingStores());
+ Assert.That(File.ReadAllBytes(file), Is.EqualTo(before));
+
+ cache.Remove(new CultRecordKey("item:anvil"));
+ Assert.Throws(() => 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();
+ 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 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(() => cache.FlushAllBackingStores());
+ Assert.That(File.ReadAllBytes(file), Is.EqualTo(swapped));
+ Assert.Throws(() => 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(() => 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(() => 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(), Records = Array.Empty() };
+ 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(() => 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> 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(() => 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(() => 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()
{
diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs
new file mode 100644
index 00000000..4312e0cb
--- /dev/null
+++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileWriteGuardTests.cs
@@ -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(() => 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(() => 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 .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();
+ snapshot.Records[0].Variant = new CultVariantDelta("item:base", Array.Empty());
+ File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
+ var before = File.ReadAllBytes(_path);
+
+ Assert.Throws(() => Write("item:a", 2));
+
+ File.ReadAllBytes(_path).Should().Equal(before);
+ }
+}
diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md
index 66b5af54..f6b8c838 100644
--- a/unity/org.gamecult.cultlib/CHANGELOG.md
+++ b/unity/org.gamecult.cultlib/CHANGELOG.md
@@ -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` 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 `.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
diff --git a/unity/org.gamecult.cultlib/README.md b/unity/org.gamecult.cultlib/README.md
index 8716d7e4..e118ac65 100644
--- a/unity/org.gamecult.cultlib/README.md
+++ b/unity/org.gamecult.cultlib/README.md
@@ -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
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll
index 65bef491..4e3bb115 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb
index 8de1fab3..ac915c7f 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.MessagePack.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll
index a1935769..c1304f4b 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb
index 1ad659c1..fa2fa001 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Caching.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll
index 2cfe3979..193fbb8c 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb
index fc1f2ee2..09aeacbc 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Logging.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll
index 578bb632..43c34a61 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb
index 47a0f04c..8d21472f 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.Quic.Native.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll
index 9d1402a7..82062269 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb
index 68e10927..6c6b6326 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Mesh.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll
index 0e655de0..d56beb3c 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.WebSockets.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll
index e6eb0f41..c4c84d10 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb
index b35f2d90..f4267efd 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb and b/unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.pdb differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll
index 5f087516..3bcdda0c 100644
Binary files a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll and b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll differ
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta
index cfeb2bd8..7c287cfa 100644
--- a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta
+++ b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll.meta
@@ -25,6 +25,12 @@ PluginImporter:
DefaultValueInitialized: true
- first:
Standalone: Win
+ second:
+ enabled: 0
+ settings:
+ CPU: None
+ - first:
+ Standalone: Win64
second:
enabled: 1
settings:
diff --git a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta
index 789b12e6..479783e5 100644
--- a/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta
+++ b/unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/msquic.dll.meta
@@ -25,6 +25,12 @@ PluginImporter:
DefaultValueInitialized: true
- first:
Standalone: Win
+ second:
+ enabled: 0
+ settings:
+ CPU: None
+ - first:
+ Standalone: Win64
second:
enabled: 1
settings:
diff --git a/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json
index 5be8ca54..4bd776bc 100644
--- a/unity/org.gamecult.cultlib/package.json
+++ b/unity/org.gamecult.cultlib/package.json
@@ -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"