diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index 319d8361..e449aa21 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -234,7 +234,7 @@ public static class CultDocumentMessagePackSerialization
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
- throw new CultStoreUnreadableException(ex.Message, ex);
+ throw new CultStoreUnreadableException(ex.Message, innerException: ex);
}
}
@@ -268,7 +268,7 @@ public static class CultDocumentMessagePackSerialization
}
catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
{
- throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", ex);
+ throw new CultStoreUnreadableException("The store is not one complete MessagePack array.", innerException: ex);
}
if (!whole.End)
diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
index f2c21616..dff49c50 100644
--- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
+++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
@@ -451,7 +451,16 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
};
}
- var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
+ CultPersistedStoreSnapshot snapshot;
+ try
+ {
+ snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(manifestBytes);
+ }
+ catch (CultStoreUnreadableException ex) when (ex.Path == null)
+ {
+ throw new CultStoreUnreadableException(ex.Message, _manifestFile.FullName, ex.InnerException);
+ }
+
if (!string.Equals(snapshot.FormatVersion, IndexedFormatVersion, StringComparison.Ordinal) &&
!string.Equals(snapshot.FormatVersion, IndexedFormatVersionWithIds, StringComparison.Ordinal))
{
diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
index a914aa04..158b9629 100644
--- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
+++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md
@@ -122,6 +122,10 @@ A reader never recovers a schema from a record's payload: a record whose schema
catalog does not publish (by id or as a compatible id) makes the store unreadable, whatever
the payload's first field says. The catalog is the store's own description of itself.
+A writer keeps the same invariant: every record it writes carries a schema id that some catalog
+entry it writes publishes, as that entry's id or as one of its compatible ids. A cache stamps a
+record with its registered schema's id, whatever id the record arrived under.
+
This is not decorative paperwork. It is what allows another CultCache
implementation to inspect an old store, resolve the schema for each record, and
decide whether it can:
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index a693d36c..f40d2d26 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -529,7 +529,9 @@ namespace GameCult.Caching
});
}
- var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal));
+ // A schema is published by its id or as a compatible id.
+ var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal))
+ ?? catalog.FirstOrDefault(entry => entry.CompatibleSchemaIds.Contains(schemaId, StringComparer.Ordinal));
if (persisted == null)
{
throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog.");
@@ -3601,7 +3603,16 @@ namespace GameCult.Caching
private CultPersistedStoreSnapshot? ReadSnapshot()
{
var bytes = ReadDisk();
- return bytes == null ? null : DeserializeSnapshot(bytes);
+ if (bytes == null)
+ return null;
+ try
+ {
+ return DeserializeSnapshot(bytes);
+ }
+ catch (CultStoreUnreadableException ex) when (ex.Path == null)
+ {
+ throw new CultStoreUnreadableException(ex.Message, FileInfo.FullName, ex.InnerException);
+ }
}
private byte[]? ReadDisk()
diff --git a/src/GameCult.Caching/CultStoreUnreadableException.cs b/src/GameCult.Caching/CultStoreUnreadableException.cs
index cb18ca3b..b3e43a4d 100644
--- a/src/GameCult.Caching/CultStoreUnreadableException.cs
+++ b/src/GameCult.Caching/CultStoreUnreadableException.cs
@@ -6,13 +6,17 @@ 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.
+ /// The underlying cause, where there is one, is the inner exception, and names the file.
///
public sealed class CultStoreUnreadableException : NotSupportedException
{
- public CultStoreUnreadableException(string message, Exception? innerException = null)
+ public CultStoreUnreadableException(string message, string? path = null, Exception? innerException = null)
: base(message, innerException)
{
+ Path = path;
}
+
+ /// The store file that was refused, or null when the bytes were read without a file.
+ public string? Path { get; }
}
}
diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
index 0221af78..f742df21 100644
--- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
+++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs
@@ -270,10 +270,9 @@ 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. Only a
- // catalog entry the current layout cannot decode (a MessagePack or shape failure) sends it there; a store refused for its
- // framing, its slot count or a record keeps that refusal.
+ // Every refusal is a CultStoreUnreadableException naming the file. 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.
+ // Only a store that is in the older layout goes there; any other refusal stands as the current reader gave it.
private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path)
{
var bytes = File.ReadAllBytes(path);
@@ -282,17 +281,55 @@ namespace GameCult.Mesh
CultPersistedStoreSnapshot snapshot;
try
{
- snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
+ try
+ {
+ snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes);
+ }
+ catch (CultStoreUnreadableException) when (HasOlderCatalogLayout(bytes))
+ {
+ snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
+ }
+
+ CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
}
- catch (CultStoreUnreadableException ex) when (ex.InnerException is MessagePackSerializationException or InvalidOperationException)
+ catch (CultStoreUnreadableException ex) when (ex.Path == null)
{
- snapshot = CultDocumentMessagePackSerialization.ReadStore(bytes, ReadLegacySchemaCatalogEntry);
+ throw new CultStoreUnreadableException(ex.Message, path, ex.InnerException);
}
- CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot);
return snapshot;
}
+ // The older layout puts the content hash, a string, at catalog entry slot 5, where the current layout has the array of
+ // compatible schema ids.
+ private static bool HasOlderCatalogLayout(byte[] bytes)
+ {
+ try
+ {
+ var reader = new MessagePackReader(bytes);
+ if (reader.ReadArrayHeader() != 3)
+ return false;
+ reader.Skip();
+ var entries = reader.ReadArrayHeader();
+ for (var entry = 0; entry < entries; entry++)
+ {
+ var fields = reader.ReadArrayHeader();
+ for (var field = 0; field < fields; field++)
+ {
+ if (field == 5 && reader.NextMessagePackType == MessagePackType.String)
+ return true;
+ reader.Skip();
+ }
+ }
+
+ return false;
+ }
+ catch (Exception ex) when (ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
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 98a8be67..d00630af 100644
--- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
+++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs
@@ -123,6 +123,15 @@ namespace GameCult.Caching.Tests
Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9"));
}
+ // Slots of the current catalog layout that do not decode are refused by name, as a malformed store is.
+ [TestCase("current-catalog-bad-canonical.msgpack")]
+ [TestCase("current-catalog-bad-members.msgpack")]
+ [TestCase("current-catalog-bad-compat.msgpack")]
+ public void SingleFileRefusesAMalformedCurrentCatalogEntry(string vector)
+ {
+ Assert.That(Refusal(vector), Is.Not.Empty);
+ }
+
[Test]
public void SingleFileRefusesExtraRecordSlotNamingTheRecord()
{
diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
index 7a7ddb55..f18c2c5a 100644
--- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
+++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs
@@ -71,7 +71,7 @@ namespace GameCult.Caching.Tests
if (reads)
Assert.DoesNotThrow(Open);
else
- Assert.That(Open, Throws.TypeOf());
+ Assert.That(Open, Throws.TypeOf().With.Property(nameof(CultStoreUnreadableException.Path)).EqualTo(path));
}
[TestCaseSource(nameof(Vectors))]
@@ -116,6 +116,7 @@ namespace GameCult.Caching.Tests
// 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("compatible-id-only-v3.bin", "cultcache.store.v3")]
[TestCase("zero-byte.bin", "cultcache.store.v1")]
[TestCase("empty-array.bin", "cultcache.store.v1")]
public void AConditionalCommitOntoAReadableFileKeepsItsHeader(string vector, string header)
diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
index 58ee9a2e..579414ee 100644
--- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
+++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
@@ -56,6 +56,16 @@ public sealed class CultMeshVariantRefusalTests
Refusal("legacy-catalog-trailing.msgpack").Message.Should().Contain("after its MessagePack array");
}
+ // A store in the current layout with one catalog slot malformed is refused as CultCache refuses it. It is not the older layout,
+ // so it is not read with the older reader, which would take the malformed canonical schema slot for a slot to skip.
+ [TestCase("current-catalog-bad-canonical.msgpack")]
+ [TestCase("current-catalog-bad-members.msgpack")]
+ [TestCase("current-catalog-bad-compat.msgpack")]
+ public void ReadSingleFileDocument_RefusesAMalformedCurrentCatalogEntry(string vector)
+ {
+ Refusal(vector).Path.Should().Be(VectorPath(vector));
+ }
+
// 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/current-catalog-bad-canonical.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-canonical.msgpack
new file mode 100644
index 00000000..69d3d9df
Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-canonical.msgpack differ
diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack
new file mode 100644
index 00000000..4270b6b5
Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-compat.msgpack differ
diff --git a/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack b/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack
new file mode 100644
index 00000000..77a04b30
Binary files /dev/null and b/tests/vectors/document-variants-c0/current-catalog-bad-members.msgpack differ
diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py
index 0965c106..4e332f16 100644
--- a/tests/vectors/document-variants-c0/generate.py
+++ b/tests/vectors/document-variants-c0/generate.py
@@ -12,6 +12,12 @@ at test time. Run: python generate.py (needs msgpack).
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
+ current-catalog-bad-canonical.msgpack
+ current-catalog-bad-members.msgpack
+ current-catalog-bad-compat.msgpack
+ v1 header, plain records, and the first catalog entry of the current layout with one slot
+ malformed (canonical schema json an int; members an int; a compatible id an int). Not the
+ older layout, so CultMesh refuses them as CultCache does
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)
@@ -49,3 +55,10 @@ write("legacy-catalog-extra-slot.msgpack",
write("legacy-catalog-plain.msgpack", [header, [legacy(entry) for entry in catalog], records])
(here / "legacy-catalog-trailing.msgpack").write_bytes(
msgpack.packb([header, [legacy(entry) for entry in catalog], records], use_bin_type=True) + b"\x01\x02\x03")
+current = catalog[0]
+for name, entry in (
+ ("current-catalog-bad-canonical", current[:4] + [5]),
+ ("current-catalog-bad-members", current[:6] + [5]),
+ ("current-catalog-bad-compat", current[:5] + [[7]] + current[6:]),
+):
+ write(name + ".msgpack", [header, [entry] + catalog[1:], records])
diff --git a/tests/vectors/document-variants-c2a/readability/compatible-id-only-v3.bin b/tests/vectors/document-variants-c2a/readability/compatible-id-only-v3.bin
new file mode 100644
index 00000000..578f9b4a
--- /dev/null
+++ b/tests/vectors/document-variants-c2a/readability/compatible-id-only-v3.bin
@@ -0,0 +1 @@
+“²cultcache.store.v3‘—±vectors.item.next¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�’”¥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/generate.py b/tests/vectors/document-variants-c2a/readability/generate.py
index 977ae280..ae7433b9 100644
--- a/tests/vectors/document-variants-c2a/readability/generate.py
+++ b/tests/vectors/document-variants-c2a/readability/generate.py
@@ -39,6 +39,8 @@ vectors = {
"missing-schema-v3": pack([base[0], [], base[2]]),
# The same, with each payload opening with its schema version: no runtime recovers a schema from a payload.
"missing-schema-versioned-v3": pack([base[0], [], [record[:3] + [pack(["vectors.item.v1", "n", 1])] for record in base[2]]]),
+ # The record names its schema by an id the catalog publishes only as a compatible id of an entry with another id.
+ "compatible-id-only-v3": pack([base[0], [[base[1][0][0] + ".next"] + base[1][0][1:]], base[2]]),
# v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload, overriding the name. Only C# reads it.
"variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]),
# v3-base with a fourth top-level slot after the records.
diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt
index beb1dd57..aac97e19 100644
--- a/tests/vectors/document-variants-c2a/readability/manifest.txt
+++ b/tests/vectors/document-variants-c2a/readability/manifest.txt
@@ -23,3 +23,4 @@ extra-top-slot.bin refuses refuses refuses refuses
missing-schema-v3.bin refuses refuses refuses refuses
variant-slot-v3.bin reads refuses refuses refuses
missing-schema-versioned-v3.bin refuses refuses refuses refuses
+compatible-id-only-v3.bin reads reads reads reads