diff --git a/README.md b/README.md index 4977a02a..e2ea607d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,30 @@ bespoke JSON bridge at every boundary. A frontend is just another CultMesh client: the daemon publishes a typed Eve/CultUI surface, and any compatible runtime can lower that surface where it needs to live. +## Entry Points + +- C# reference runtime: `src/` (`GameCult.Caching`, `GameCult.Networking`, + `GameCult.Mesh`, `GameCult.Geometry`, `GameCult.Unity`). Build and test with + `dotnet build CultLib.sln` and `dotnet test CultLib.sln`. +- Other runtimes, wire-compatible with the C# CultCache: `packages/cultcache-ts`, + `cultcache-py`, `cultcache-rs`, and the CultNet and CultMesh packages beside + them under `packages/`; portable math in `packages/cultmath`. +- Smallest working path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. + Application path: `src/GameCult.Mesh/docs/getting-started/README.md` and + `samples/eve-two-runtime`. + +## Live And Planned + +Live: typed CultCache persistence, CultNet schema-v0 transports and native RUDP, +CultMesh nodes with Verse discovery, shard replication and authority leases, +the geometry documents, and CultUI for Unity. The browser client boundary +(`packages/cultmesh-browser`, `GameCult.Networking.WebSockets`) is proven on +loopback under an explicit `local-development` trust policy by +`samples/eve-browser-network`. + +Planned: a complete public Verse proof over deployed infrastructure; the +remaining gates are listed in `src/GameCult.Mesh/docs/browser-verse-transport.md`. + ## Which Package Do I Want? If the job is shared game state, start here before inventing a fourth drawer @@ -97,7 +121,6 @@ The solution includes: - `GameCult.Logging`: common logging abstraction plus console and file implementations - `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions - `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache -- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache - `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers - `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents - `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions @@ -125,7 +148,6 @@ src/ GameCult.Logging/ GameCult.Caching/ GameCult.Caching.MessagePack/ - GameCult.Caching.NewtonsoftJson/ GameCult.Geometry/ GameCult.Networking/ GameCult.Networking.WebSockets/ @@ -344,7 +366,6 @@ Each subproject has a local README with package-specific detail: - [GameCult.Logging](src/GameCult.Logging/README.md) - [GameCult.Caching](src/GameCult.Caching/README.md) - [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md) -- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md) - [GameCult.Geometry](src/GameCult.Geometry/README.md) - [GameCult.Networking](src/GameCult.Networking/README.md) - [GameCult.Mesh](src/GameCult.Mesh/README.md) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index c037626d..cb3d2b75 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1087,7 +1087,22 @@ namespace GameCult.Caching errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'"); } - if (localMember.IsReference != persistedMember.IsReference) + // 1.0.x persisted a CultRecordRef collection as a plain value slot (not a reference, no target); the + // payload is the same array of keys. Gaining the reference classification on an otherwise identical slot + // is compatible drift. Losing it, or any other change of reference or target, is not. + var gainedReference = !persistedMember.IsReference + && persistedMember.TargetSchemaName == null + && localMember.IsReference + && localMember.TargetSchemaName != null; + if (gainedReference) + { + warnings.Add(new CultSchemaMigrationWarning + { + Code = "gained_reference_semantics", + Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is a reference to '{localMember.TargetSchemaName}'; the persisted schema stored it as a plain value with the same payload." + }); + } + else if (localMember.IsReference != persistedMember.IsReference) { errors.Add($"slot {localMember.Slot} changed reference semantics"); } @@ -1097,7 +1112,7 @@ namespace GameCult.Caching errors.Add($"slot {localMember.Slot} changed cardinality"); } - if (!string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal)) + if (!gainedReference && !string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal)) { errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? ""}' -> '{localMember.TargetSchemaName ?? ""}'"); } diff --git a/tests/GameCult.Caching.Tests/RefListDriftTests.cs b/tests/GameCult.Caching.Tests/RefListDriftTests.cs new file mode 100644 index 00000000..2cb52167 --- /dev/null +++ b/tests/GameCult.Caching.Tests/RefListDriftTests.cs @@ -0,0 +1,169 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // 1.0.x persisted a list of record references (CultRecordRef[]) as a plain value slot: isReference false, no target. + // 2.0 classifies the same slot as a reference to T. The payload is the same array of key strings, so a store written by + // 1.0.x opens: gaining reference classification on an otherwise identical slot is compatible drift. A reference that is + // lost, retargeted, or that arrives with a different type or cardinality stays a refusal. + public class RefListDriftTests + { + [CultDocument("tests.reflist_target", "tests.reflist_target.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListTarget + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.reflist_holder", "tests.reflist_holder.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListHolder + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef[] Items = Array.Empty>(); + } + + // The reverse of the 1.0.x drift: a slot persisted as a reference list, declared by a later version as a plain value. + [CultDocument("tests.reflist_loss", "tests.reflist_loss.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListLossBefore + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef[] Items = Array.Empty>(); + } + + [CultDocument("tests.reflist_loss", "tests.reflist_loss.v2")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RefListLossAfter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public string[] Items = Array.Empty(); + } + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift"); + if (Directory.Exists(vectors)) return Path.Combine(vectors, name); + } + + throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}."); + } + + [Test] + public async Task A_1_0_Store_With_A_Ref_List_Slot_Opens_And_Rewrites_Under_The_Reference_Schema() + { + var copy = Path.Combine(Path.GetTempPath(), $"reflist-drift-{Guid.NewGuid():N}.cc"); + File.Copy(VectorPath("old-store.cc"), copy); + try + { + using var cache = new CultCache(); + var store = new SingleFileMessagePackBackingStore(copy); + cache.AddBackingStore(store); + await cache.PullAllBackingStoresAsync(); + Assert.That( + store.LastSchemaMigrationReports.Single(report => report.PersistedSchemaName == "tests.reflist_holder").Kind, + Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + + var holder = cache.GetAll().Single(); + Assert.That(holder.Items.Select(item => item.Key.Value), Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(cache.Get(holder.Items[0].Key)!.Name, Is.EqualTo("alpha")); + + // A write flushes the store under the 2.0 schema; the list reads back unchanged. + await cache.AddAsync(new RefListTarget { Name = "gamma" }); + store.PushAll(); + using var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(copy)); + await reopened.PullAllBackingStoresAsync(); + Assert.That(string.Join(",", reopened.GetAll().Select(target => target.Name).OrderBy(name => name)), Is.EqualTo("alpha,beta,gamma")); + Assert.That(reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "alpha", "beta" })); + } + finally + { + File.Delete(copy); + } + } + + private static CultSchemaMigrationReport Resolve(Action persistedItems) + { + var catalog = CultDocumentRegistry.Shared.GetRequired().ToCatalogEntry(); + catalog.SchemaId = "persisted.tests.reflist_holder.v1"; + catalog.ContentHash = "sha256:persisted-by-1.0.x"; + catalog.CompatibleSchemaIds = new[] { CultDocumentRegistry.Shared.GetRequired().SchemaId }; + persistedItems(catalog.Members.Single(member => member.Slot == 1)); + return CultDocumentRegistry.Shared.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }); + } + + private static void AsWrittenBy10(CultSchemaMemberCatalogEntry member) + { + member.IsReference = false; + member.TargetSchemaName = null; + } + + [Test] + public void A_Reference_Gained_On_An_Otherwise_Identical_Slot_Is_Compatible_Drift() + { + var report = Resolve(AsWrittenBy10); + Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + Assert.That(report.Warnings.Select(warning => warning.Code), Does.Contain("gained_reference_semantics")); + } + + [Test] + public void A_Persisted_Reference_Retargeted_Or_Gaining_Over_A_Foreign_Target_Still_Refuses() + { + Assert.That( + () => Resolve(member => member.TargetSchemaName = "tests.other_target"), + Throws.TypeOf().With.Message.Contains("changed target schema")); + Assert.That( + () => Resolve(member => { member.IsReference = false; member.TargetSchemaName = "tests.other_target"; }), + Throws.TypeOf()); + } + + [Test] + public void A_Lost_Reference_Still_Refuses() + { + var registry = CultDocumentRegistry.Shared; + var catalog = registry.GetRequired().ToCatalogEntry(); + catalog.SchemaId = "persisted.tests.reflist_loss.v1"; + catalog.ContentHash = "sha256:persisted-by-2.0"; + catalog.CompatibleSchemaIds = new[] { registry.GetRequired().SchemaId }; + catalog.Members.Single(member => member.Slot == 1).TypeName = "System.String[]"; + Assert.That( + () => registry.ResolvePersistedSchemaReport(catalog.SchemaId, new[] { catalog }), + Throws.TypeOf().With.Message.Contains("changed reference semantics")); + } + + [Test] + public void A_Gained_Reference_Does_Not_Excuse_A_Changed_Type_Or_Cardinality() + { + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.TypeName = "System.String[]"; }), + Throws.TypeOf().With.Message.Contains("changed type")); + Assert.That( + () => Resolve(member => { AsWrittenBy10(member); member.IsMany = !member.IsMany; }), + Throws.TypeOf().With.Message.Contains("changed cardinality")); + } + } +} diff --git a/tests/vectors/ref-list-drift/README.md b/tests/vectors/ref-list-drift/README.md new file mode 100644 index 00000000..639bf6d5 --- /dev/null +++ b/tests/vectors/ref-list-drift/README.md @@ -0,0 +1,10 @@ +# ref-list-drift vector + +`old-store.cc` is a `cultcache.store.v1` file in the shape CultLib 1.0.x wrote for a list of record references: the +`Items` slot (`CultRecordRef[]`) is persisted with `isReference:false` and no target schema, and its +payload is an array of key strings. Its layout is copied from a real 1.0.x-written store (Aetheria `run.cc`, schema +`aetheria.savedgame`, slots `Zones` and `Factions`), pruned to two schemas and three records: `alpha` and `beta` +(`tests.reflist_target`) and `list` (`tests.reflist_holder`, `Items = [alpha, beta]`). + +Hand-written by a script outside the runtime under test; never regenerated by CultCache. A change to this file is a +reviewed commit. diff --git a/tests/vectors/ref-list-drift/old-store.cc b/tests/vectors/ref-list-drift/old-store.cc new file mode 100644 index 00000000..eb301a57 Binary files /dev/null and b/tests/vectors/ref-list-drift/old-store.cc differ diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 2439a752..dd9b682a 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -9,6 +9,16 @@ All notable changes to this package are documented in this file. - A single-file store flush refuses a file it cannot read (empty, truncated, an unreadable stamp or body) with `CultStoreUnreadableException` and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. - A single-file store reads v1, v2 and v3 stamps and refuses v4 and later by name; a directory store reads v4 and v5 and refuses v6 and later by name. Each judges the stamp before the body, so an unreadable body never makes a newer store look like damage. +## [2.0.1] - 2026-10-22 + +### Fixed + +- A store written by 1.0.x with a list of record references (`CultRecordRef[]` or any `IEnumerable>` slot) + opens again. 2.0.0 classified such a slot as a reference to `T`, 1.0.x had persisted it as a plain value, and the schema + comparison refused the store with "changed reference semantics" and "changed target schema". The payload is the same array + of keys, so gaining the reference classification on an otherwise identical slot is now compatible drift (migration warning + `gained_reference_semantics`). A reference that is lost or retargeted, and any change of type or cardinality, still refuses. + ## [2.0.0] - 2026-10-22 ### Breaking 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 4e3bb115..bf05fec2 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 ac915c7f..bfe11807 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 c1304f4b..8e7b88f1 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 fa2fa001..3fb019e1 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 193fbb8c..2dd55355 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 09aeacbc..1635f093 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 43c34a61..6e138fd5 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 8d21472f..021de32d 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 82062269..ceb2fbf4 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 6c6b6326..3074fbd6 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 d56beb3c..21e64ce1 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 c4c84d10..dd6482f9 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 f4267efd..4fcf53c6 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/package.json b/unity/org.gamecult.cultlib/package.json index 4bd776bc..19e42b58 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": "2.0.0", + "version": "2.0.1", "unity": "2021.3", "dependencies": { "org.gamecult.cultmath": "0.5.0"