diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index d1a4df52..579eddb6 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -178,49 +178,62 @@ public static class CultDocumentMessagePackSerialization
}
///
- /// Reads a store file: exactly one complete MessagePack array. A truncated file, bytes after the array, a value that is not
- /// an array, or a first slot that is not a header string are refused, so a reader never takes part of a file for the whole.
- /// The rewriting paths use this same reader as their verdict on whether a file may be replaced.
+ /// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty
+ /// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that
+ /// is not a header string, a store with fewer or more slots, or a body that does not decode are refused with
+ /// , so a reader never takes part of a file for the whole and never skips a slot
+ /// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced.
///
- public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload)
+ public static CultPersistedStoreSnapshot DeserializeSnapshot(byte[] payload) => ReadStore(payload, ReadSchemaCatalogEntry);
+
+ internal delegate CultSchemaCatalogEntry CatalogEntryReader(ref MessagePackReader reader);
+
+ // The one store reader. A caller with an older catalog layout supplies its own entry reader; the framing, the slot count
+ // and the records are judged here, once.
+ internal static CultPersistedStoreSnapshot ReadStore(byte[] payload, CatalogEntryReader readCatalogEntry)
{
- RequireOneArray(payload);
- var reader = new MessagePackReader(payload);
- var fieldCount = reader.ReadArrayHeader();
- var snapshot = new CultPersistedStoreSnapshot();
-
- if (fieldCount > 0)
+ try
{
+ RequireOneArray(payload);
+ var reader = new MessagePackReader(payload);
+ var fieldCount = reader.ReadArrayHeader();
+ if (fieldCount != 0 && fieldCount != StoreSnapshotFieldCount)
+ {
+ throw new CultStoreUnreadableException(
+ $"The store has {fieldCount} top-level slots; this runtime reads {StoreSnapshotFieldCount} (header, schema catalog, records) or none.");
+ }
+
+ var snapshot = new CultPersistedStoreSnapshot();
+ if (fieldCount == 0)
+ return snapshot;
+
snapshot.FormatVersion = reader.ReadString()
- ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + ".");
- }
+ ?? throw new CultStoreUnreadableException("Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot.FormatV1 + ", " + CultPersistedStoreSnapshot.FormatV2 + " and " + CultPersistedStoreSnapshot.FormatV3 + ".");
- if (fieldCount > 1)
- {
var catalogCount = reader.ReadArrayHeader();
snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount];
for (var index = 0; index < catalogCount; index++)
{
- snapshot.SchemaCatalog[index] = ReadSchemaCatalogEntry(ref reader);
+ snapshot.SchemaCatalog[index] = readCatalogEntry(ref reader);
}
- }
- if (fieldCount > 2)
- {
var recordCount = reader.ReadArrayHeader();
snapshot.Records = new CultPersistedRecord[recordCount];
for (var index = 0; index < recordCount; index++)
{
snapshot.Records[index] = ReadPersistedRecord(ref reader);
}
- }
- for (var index = StoreSnapshotFieldCount; index < fieldCount; index++)
+ return snapshot;
+ }
+ catch (CultStoreUnreadableException)
{
- reader.Skip();
+ throw;
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ throw new CultStoreUnreadableException(ex.Message, ex);
}
-
- return snapshot;
}
private static void RequireOneArray(byte[] bytes)
@@ -229,16 +242,16 @@ public static class CultDocumentMessagePackSerialization
try
{
if (whole.NextMessagePackType != MessagePackType.Array)
- throw new NotSupportedException($"The store is a MessagePack {whole.NextMessagePackType}, not an array.");
+ throw new CultStoreUnreadableException($"The store is a MessagePack {whole.NextMessagePackType}, not an array.");
whole.Skip();
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
- throw new NotSupportedException("The store is not one complete MessagePack array.", ex);
+ throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", ex);
}
if (!whole.End)
- throw new NotSupportedException("The store has bytes after its MessagePack array.");
+ throw new CultStoreUnreadableException("The store has bytes after its MessagePack array.");
}
/// Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.
@@ -249,7 +262,7 @@ public static class CultDocumentMessagePackSerialization
!string.Equals(version, CultPersistedStoreSnapshot.FormatV2, StringComparison.Ordinal) &&
!string.Equals(version, CultPersistedStoreSnapshot.FormatV3, StringComparison.Ordinal))
{
- throw new NotSupportedException(
+ throw new CultStoreUnreadableException(
$"Store format {version} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}.");
}
@@ -259,7 +272,7 @@ public static class CultDocumentMessagePackSerialization
var variant = snapshot.Records.FirstOrDefault(record => record.Variant != null);
if (variant != null)
{
- throw new NotSupportedException(
+ throw new CultStoreUnreadableException(
$"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2} or {CultPersistedStoreSnapshot.FormatV3}.");
}
}
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-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
index d887e44e..31775962 100644
--- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
+++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
@@ -230,14 +230,20 @@ rest, so a file already declared `v3` stays `v3` when it merges onto it. A direc
store's manifest says `cultcache.store.v5.directory-content-addressed-pages` under the
same rule, and `v4` otherwise.
-A single-file store is exactly one MessagePack array. Every runtime refuses a file that is
-truncated, has bytes after the array (a second store appended included), is not an array, or
-whose first slot is not a header it reads; it refuses these on open and on rewrite alike. A
-zero-byte file is an empty store. A flush or a commit replaces a file only when the
+A single-file store is exactly one MessagePack array of three slots: the header, the schema
+catalog and the records. Every runtime refuses a file that is truncated, has bytes after the
+array (a second store appended included), is not an array, has fewer or more than three slots
+(a header alone included), whose first slot is not a header it reads, or whose catalog or
+records it cannot decode (a record with more slots than its header allows, a variant record
+it does not read). A runtime never skips a slot it does not understand. It refuses on open
+and on rewrite alike, because the rewrite asks the same full reader as the open. A zero-byte
+file and an empty array are an empty store. A flush or a commit replaces a file only when the
runtime's own reader opens it, so one verdict covers open, flush and commit, and a file the
-runtime cannot read is left as it is. Rust, TypeScript and Python also open the legacy
-envelope array (an array of `key`/`type`/`payload`/`storedAt` maps) and replace it; C# has
-no legacy reader and refuses it. The shared byte vectors and each runtime's verdict are
+runtime cannot read is left as it is. Each runtime refuses with one typed error:
+`CultStoreUnreadableException` (C#), `StoreUnreadableError` (Rust, TypeScript, Python), with
+the underlying cause attached. Rust, TypeScript and Python also open the legacy envelope
+array (an array of `key`/`type`/`payload`/`storedAt` maps) and replace it; C# has no legacy
+reader and refuses it. The shared byte vectors and each runtime's verdict are
`tests/vectors/document-variants-c2a/readability/manifest.txt`.
## Variant Records
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index e12ee0b9..a693d36c 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -3419,8 +3419,9 @@ namespace GameCult.Caching
///
/// This store's one reader of a non-empty store file, and so its one verdict on whether the file may be replaced: open,
- /// flush and commit all ask it. It must throw for anything that is not exactly one store this runtime reads (a
- /// truncated file, bytes after the store, a format it does not know), so a rewrite never overwrites a file it cannot see.
+ /// flush and commit all ask it. It must throw for anything that is not exactly one
+ /// store this runtime reads (a truncated file, bytes after the store, a missing or extra slot, a format or record it does
+ /// not know), so a rewrite never overwrites a file it cannot see.
///
protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data);
diff --git a/src/GameCult.Caching/CultStoreUnreadableException.cs b/src/GameCult.Caching/CultStoreUnreadableException.cs
new file mode 100644
index 00000000..cb18ca3b
--- /dev/null
+++ b/src/GameCult.Caching/CultStoreUnreadableException.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace GameCult.Caching
+{
+ ///
+ /// A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a value that is not a
+ /// store, a missing or extra top-level slot), a format or record it does not know, or a body it cannot decode. Open, flush,
+ /// commit and CultMesh's single-file reads all refuse a file with this one exception, and a refused file is left as it was.
+ /// The underlying cause, where there is one, is the inner exception.
+ ///
+ public sealed class CultStoreUnreadableException : NotSupportedException
+ {
+ public CultStoreUnreadableException(string message, Exception? innerException = null)
+ : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 2a2ff2f6..8b6b8a58 100644
--- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
+++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
@@ -270,6 +270,8 @@ namespace GameCult.Mesh
WriteFileAtomically(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
}
+ // Every refusal is a CultStoreUnreadableException. A store whose catalog entries are in the older layout is read by the
+ // same store reader with the older entry reader, so it is judged by the same framing, slot count and records.
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{
var bytes = File.ReadAllBytes(path);
@@ -280,46 +282,15 @@ namespace GameCult.Mesh
{
snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
}
- catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException)
+ catch (CultStoreUnreadableException)
{
- snapshot = ReadLegacySingleFileSnapshot(bytes);
+ snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
}
CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}
- private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes)
- {
- var reader = new MessagePackReader(bytes);
- var fieldCount = reader.ReadArrayHeader();
- var snapshot = new CultPersistedStoreSnapshot();
- if (fieldCount > 0)
- snapshot.FormatVersion = reader.ReadString()
- ?? throw new NotSupportedException("Store snapshot declares no format version.");
-
- if (fieldCount > 1)
- {
- var catalogCount = reader.ReadArrayHeader();
- snapshot.SchemaCatalog = new CultSchemaCatalogEntry[catalogCount];
- for (var index = 0; index < catalogCount; index++)
- snapshot.SchemaCatalog[index] = ReadLegacySchemaCatalogEntry(ref reader);
- }
-
- if (fieldCount > 2)
- {
- var recordCount = reader.ReadArrayHeader();
- snapshot.Records = new CultPersistedRecord[recordCount];
- for (var index = 0; index < recordCount; index++)
- snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader);
- }
-
- for (var index = 3; index < fieldCount; index++)
- reader.Skip();
-
- return snapshot;
- }
-
private static CultSchemaCatalogEntry ReadLegacySchemaCatalogEntry(ref MessagePackReader reader)
{
var fieldCount = reader.ReadArrayHeader();
diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
index 181eca56..98a8be67 100644
--- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
+++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
@@ -108,7 +108,7 @@ namespace GameCult.Caching.Tests
using var cache = new CultCache();
var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)));
Assert.That(error, Is.Not.Null);
- Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException)));
+ Assert.That(error, Is.TypeOf());
return error.ToString();
}
finally
@@ -164,7 +164,7 @@ namespace GameCult.Caching.Tests
var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 };
Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes),
- Throws.TypeOf().With.Message.Contains("format version"));
+ Throws.TypeOf().With.Message.Contains("format version"));
}
[Test]
diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
index 535390e1..7abee9a5 100644
--- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
+++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
@@ -76,7 +76,7 @@ namespace GameCult.Caching.Tests
if (reads)
Assert.DoesNotThrow(Open);
else
- Assert.That(Open, Throws.Exception);
+ Assert.That(Open, Throws.TypeOf());
}
[TestCaseSource(nameof(Vectors))]
@@ -91,11 +91,12 @@ namespace GameCult.Caching.Tests
if (reads)
{
store.PushAll();
- Assert.That(Header(path), Does.StartWith("cultcache.store.v"), "the file is a store this runtime wrote");
+ // A whole-store flush writes what the store holds: one deck with no element ids is v1, whatever the file was.
+ Assert.That(Header(path), Is.EqualTo("cultcache.store.v1"));
return;
}
- Assert.That(() => store.PushAll(), Throws.Exception);
+ Assert.That(() => store.PushAll(), Throws.TypeOf());
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten");
}
@@ -110,14 +111,50 @@ namespace GameCult.Caching.Tests
if (reads)
{
cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")));
- Assert.That(Header(path), Does.StartWith("cultcache.store.v"), "the file is a store this runtime wrote");
+ Assert.That(Header(path), Is.EqualTo("cultcache.store.v1"), "an unconditional commit writes the store's whole view, which holds no element id");
return;
}
- Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.Exception);
+ Assert.That(() => cache.Commit(batch => batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"))), Throws.TypeOf());
Assert.That(File.ReadAllBytes(path), Is.EqualTo(bytes), "a file this runtime cannot read was rewritten");
}
+ // A commit that lands onto the file as it is keeps the header the file carries: a store already v3 stays v3.
+ [TestCase("../v3-base.msgpack", "cultcache.store.v3")]
+ [TestCase("zero-byte.bin", "cultcache.store.v1")]
+ [TestCase("empty-array.bin", "cultcache.store.v1")]
+ public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header)
+ {
+ var path = Seed("conditional.cc");
+ using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry });
+ File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector)));
+
+ Assert.That(cache.Commit(batch =>
+ {
+ batch.Expect(new CultRecordKey("e"), null);
+ batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e"));
+ }), Is.True);
+ Assert.That(Header(path), Is.EqualTo(header));
+ }
+
+ // Every runtime's tests walk the manifest, so a vector without a row would go untested.
+ [Test]
+ public void EveryVectorInTheFolderHasAManifestRow()
+ {
+ var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal);
+ var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
+ Assert.That(rows, Is.EqualTo(files));
+ }
+
+ // The refusal carries what the reader choked on.
+ [Test]
+ public void ARefusalKeepsItsCauseAsTheInnerException()
+ {
+ var error = Assert.Throws(() =>
+ CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(Path.Combine(VectorRoot(), "truncated.bin"))))!;
+ Assert.That(error.InnerException, Is.Not.Null);
+ }
+
// The record type of the shared valid store (v3-base.msgpack: alpha and beta), so a cache can open it.
[CultDocument("vectors.item", "vectors.item.v1")]
[MessagePackObject]
diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs
index c8d7a8df..7567c640 100644
--- a/tests/GameCult.Caching.Tests/VariantTests.cs
+++ b/tests/GameCult.Caching.Tests/VariantTests.cs
@@ -1294,7 +1294,7 @@ namespace GameCult.Caching.Tests
public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord()
{
var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base"));
- var error = Assert.Throws(() => Open(path).Dispose())!;
+ var error = Assert.Throws(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2"));
}
@@ -1302,7 +1302,7 @@ namespace GameCult.Caching.Tests
public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord()
{
var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 }));
- var error = Assert.Throws(() => Open(path).Dispose())!;
+ var error = Assert.Throws(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload"));
}
@@ -1310,7 +1310,7 @@ namespace GameCult.Caching.Tests
public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord()
{
var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7));
- var error = Assert.Throws(() => Open(path).Dispose())!;
+ var error = Assert.Throws(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
}
diff --git a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs
index 1831ce28..e3566b54 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshSingleFileReadabilityTests.cs
@@ -77,8 +77,16 @@ public sealed class CultMeshSingleFileReadabilityTests
continue;
}
- Assert.That(() => write(path), Throws.Exception, name);
+ Assert.That(() => write(path), Throws.TypeOf(), name);
File.ReadAllBytes(path).Should().Equal(bytes, name + " rewrote a file this runtime cannot read");
}
}
+
+ [Test]
+ public void EveryVectorInTheFolderHasAManifestRow()
+ {
+ var rows = Vectors().Select(row => (string)row.Arguments[0]).Where(name => !name.StartsWith("..", StringComparison.Ordinal)).OrderBy(name => name, StringComparer.Ordinal);
+ var files = Directory.GetFiles(VectorRoot(), "*.bin").Select(Path.GetFileName).OrderBy(name => name, StringComparer.Ordinal);
+ rows.Should().Equal(files);
+ }
}
diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
index 2521aa8a..58ee9a2e 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
@@ -23,8 +23,8 @@ public sealed class CultMeshVariantRefusalTests
throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
}
- private static NotSupportedException Refusal(string vector) =>
- Assert.Throws(() =>
+ private static CultStoreUnreadableException Refusal(string vector) =>
+ Assert.Throws(() =>
CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!;
[Test]
@@ -45,6 +45,17 @@ public sealed class CultMeshVariantRefusalTests
message.Should().Contain("item:anvil").And.Contain(ItemSchemaId);
}
+ // The older catalog layout is read by the same store reader, so it is held to one array too: the same store with bytes after it
+ // is refused, and only the bytes make the difference.
+ [Test]
+ public void ReadSingleFileDocument_ReadsTheLegacyCatalogLayoutButNotWithBytesAfterIt()
+ {
+ CultMesh.ReadSingleFileDocumentPayload(VectorPath("legacy-catalog-plain.msgpack"), new CultRecordKey("item:bellows"), ItemSchemaId)
+ .Should().NotBeEmpty();
+
+ Refusal("legacy-catalog-trailing.msgpack").Message.Should().Contain("after its MessagePack array");
+ }
+
// Q6: CultMesh does not resolve variants. It refuses a variant key naming it and its base, and still reads the plain
// records of the same v2 store.
[Test]
diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py
index c6e5d2da..b649475f 100644
--- a/tests/vectors/document-variants-c0/generate.py
+++ b/tests/vectors/document-variants-c0/generate.py
@@ -8,6 +8,10 @@ at test time. Run: python generate.py (needs msgpack).
v1 header, catalog entry in the older layout (contentHash at slot
5), so CultMesh's legacy snapshot reader takes it, plus the
extra-slot record
+ legacy-catalog-plain.msgpack v1 header, the older-layout catalog entry, the plain v1 records: a store CultMesh's
+ legacy reader accepts
+ legacy-catalog-trailing.msgpack legacy-catalog-plain plus three bytes after the array: one array is
+ required of the legacy layout too
v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib
69a21bb (SingleFileMessagePackBackingStore.push of alpha and
beta, type vectors.item, before any C0 code existed)
@@ -41,3 +45,6 @@ write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]]
legacy_entry = [catalog[0][0], catalog[0][1], catalog[0][2], catalog[0][4], None, catalog[0][3], []]
write("legacy-catalog-extra-slot.msgpack",
[header, [legacy_entry], [anvil + [["item:bellows", []]]]])
+write("legacy-catalog-plain.msgpack", [header, [legacy_entry], records])
+(here / "legacy-catalog-trailing.msgpack").write_bytes(
+ msgpack.packb([header, [legacy_entry], records], use_bin_type=True) + b"\x01\x02\x03")
diff --git a/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack
new file mode 100644
index 00000000..6759a9b5
--- /dev/null
+++ b/tests/vectors/document-variants-c0/legacy-catalog-plain.msgpack
@@ -0,0 +1,2 @@
+“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�“”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä
+’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack
new file mode 100644
index 00000000..8c300126
--- /dev/null
+++ b/tests/vectors/document-variants-c0/legacy-catalog-trailing.msgpack
@@ -0,0 +1,2 @@
+“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�“”ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil”¬item:bellowsÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4114746+00:00Ä
+’§bellows*”«note:originÙGsha256:670deacd06aedb90235e6bd873674c10467a05b4adcd62dbc019279efcbcbb11Ù!2026-09-13T15:14:36.4120244+00:00Ä‘´written before cut 2
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin b/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin
new file mode 100644
index 00000000..da41bc1e
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/bad-body-v1.bin
@@ -0,0 +1 @@
+“²cultcache.store.v1
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin b/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin
new file mode 100644
index 00000000..591535f4
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/bad-body-v3.bin
@@ -0,0 +1 @@
+“²cultcache.store.v3
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin b/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin
new file mode 100644
index 00000000..1c7dbbef
Binary files /dev/null and b/tests/vectors/document-variants-c2a/readability/extra-top-slot.bin differ
diff --git a/tests/vectors/document-variants-c2a/readability/generate.py b/tests/vectors/document-variants-c2a/readability/generate.py
index 288862ad..7680a886 100644
--- a/tests/vectors/document-variants-c2a/readability/generate.py
+++ b/tests/vectors/document-variants-c2a/readability/generate.py
@@ -11,6 +11,7 @@ import msgpack
here = pathlib.Path(__file__).resolve().parent
whole = (here.parent / "v3-base.msgpack").read_bytes()
pack = lambda value: msgpack.packb(value, use_bin_type=True)
+base = msgpack.unpackb(whole, raw=False)
vectors = {
"zero-byte": b"",
@@ -28,8 +29,20 @@ vectors = {
"truncated": whole[:-1],
"trailing-bytes": whole + b"\x01\x02\x03",
"trailing-store": whole + whole,
+ # A header and a body of the wrong shape, for both readable headers.
+ "bad-body-v1": pack(["cultcache.store.v1", 5, 6]),
+ "bad-body-v3": pack(["cultcache.store.v3", 5, 6]),
+ # A header and nothing else: not a store, whatever the header says.
+ "header-only-v1": pack(["cultcache.store.v1"]),
+ "header-only-v3": pack(["cultcache.store.v3"]),
+ # v3-base with its schema catalog emptied: every record names a schema the store does not publish.
+ "missing-schema-v3": pack([base[0], [], base[2]]),
+ # v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload. Only C# reads it.
+ "variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", []]]]]),
+ # v3-base with a fourth top-level slot after the records.
+ "extra-top-slot": pack(base + [0]),
}
-vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b""
+vectors["legacy-trailing"] = vectors["legacy-envelopes"] + b"\x01\x02\x03"
for name, data in vectors.items():
(here / f"{name}.bin").write_bytes(data)
print(len(vectors), "vectors")
diff --git a/tests/vectors/document-variants-c2a/readability/header-only-v1.bin b/tests/vectors/document-variants-c2a/readability/header-only-v1.bin
new file mode 100644
index 00000000..29dc02fa
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/header-only-v1.bin
@@ -0,0 +1 @@
+‘²cultcache.store.v1
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/header-only-v3.bin b/tests/vectors/document-variants-c2a/readability/header-only-v3.bin
new file mode 100644
index 00000000..17e7a16d
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/header-only-v3.bin
@@ -0,0 +1 @@
+‘²cultcache.store.v3
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt
index 8675d6b9..626d8d7b 100644
--- a/tests/vectors/document-variants-c2a/readability/manifest.txt
+++ b/tests/vectors/document-variants-c2a/readability/manifest.txt
@@ -15,3 +15,10 @@ map.bin refuses refuses refuses refuses
truncated.bin refuses refuses refuses refuses
trailing-bytes.bin refuses refuses refuses refuses
trailing-store.bin refuses refuses refuses refuses
+bad-body-v1.bin refuses refuses refuses refuses
+bad-body-v3.bin refuses refuses refuses refuses
+header-only-v1.bin refuses refuses refuses refuses
+header-only-v3.bin refuses refuses refuses refuses
+extra-top-slot.bin refuses refuses refuses refuses
+missing-schema-v3.bin reads refuses refuses refuses
+variant-slot-v3.bin reads refuses refuses refuses
diff --git a/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin b/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin
new file mode 100644
index 00000000..5169c46f
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/missing-schema-v3.bin
@@ -0,0 +1 @@
+“²cultcache.store.v3�’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta
\ No newline at end of file
diff --git a/tests/vectors/document-variants-c2a/readability/variant-slot-v3.bin b/tests/vectors/document-variants-c2a/readability/variant-slot-v3.bin
new file mode 100644
index 00000000..f2f09047
Binary files /dev/null and b/tests/vectors/document-variants-c2a/readability/variant-slot-v3.bin differ