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"