Store readability: one typed refusal, one full reader, one store shape

CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.

Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 04:06:44 +02:00
commit 436eed73e4
23 changed files with 186 additions and 84 deletions

View file

@ -178,49 +178,62 @@ public static class CultDocumentMessagePackSerialization
}
/// <summary>
/// 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
/// <see cref="CultStoreUnreadableException"/>, 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.
/// </summary>
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.");
}
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.</summary>
@ -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}.");
}
}

View file

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

View file

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

View file

@ -3419,8 +3419,9 @@ namespace GameCult.Caching
/// <summary>
/// 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 <see cref="CultStoreUnreadableException"/> 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.
/// </summary>
protected abstract CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data);

View file

@ -0,0 +1,18 @@
using System;
namespace GameCult.Caching
{
/// <summary>
/// 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.
/// </summary>
public sealed class CultStoreUnreadableException : NotSupportedException
{
public CultStoreUnreadableException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
}

View file

@ -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();

View file

@ -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<CultStoreUnreadableException>());
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<NotSupportedException>().With.Message.Contains("format version"));
Throws.TypeOf<CultStoreUnreadableException>().With.Message.Contains("format version"));
}
[Test]

View file

@ -76,7 +76,7 @@ namespace GameCult.Caching.Tests
if (reads)
Assert.DoesNotThrow(Open);
else
Assert.That(Open, Throws.Exception);
Assert.That(Open, Throws.TypeOf<CultStoreUnreadableException>());
}
[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<CultStoreUnreadableException>());
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<CultStoreUnreadableException>());
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<CultStoreUnreadableException>(() =>
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]

View file

@ -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<NotSupportedException>(() => Open(path).Dispose())!;
var error = Assert.Throws<CultStoreUnreadableException>(() => 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<NotSupportedException>(() => Open(path).Dispose())!;
var error = Assert.Throws<CultStoreUnreadableException>(() => 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<NotSupportedException>(() => Open(path).Dispose())!;
var error = Assert.Throws<CultStoreUnreadableException>(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
}

View file

@ -77,8 +77,16 @@ public sealed class CultMeshSingleFileReadabilityTests
continue;
}
Assert.That(() => write(path), Throws.Exception, name);
Assert.That(() => write(path), Throws.TypeOf<CultStoreUnreadableException>(), 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);
}
}

View file

@ -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<NotSupportedException>(() =>
private static CultStoreUnreadableException Refusal(string vector) =>
Assert.Throws<CultStoreUnreadableException>(() =>
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]

View file

@ -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")

View file

@ -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<30>“”ª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

View file

@ -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<30>“”ª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

View file

@ -0,0 +1 @@
“²cultcache.store.v1

View file

@ -0,0 +1 @@
“²cultcache.store.v3

View file

@ -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")

View file

@ -0,0 +1 @@
‘²cultcache.store.v1

View file

@ -0,0 +1 @@
‘²cultcache.store.v3

View file

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

View file

@ -0,0 +1 @@
“²cultcache.store.v3<76>’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta