511 lines
18 KiB
Markdown
511 lines
18 KiB
Markdown
# CultMesh
|
|
|
|
CultMesh is the distributed realtime database and simulation-consensus layer for
|
|
GameCult projects. The NuGet package and assembly are named `GameCult.Mesh`;
|
|
the public surface is branded as CultMesh.
|
|
|
|
CultMesh sits over:
|
|
|
|
- `GameCult.Caching` for typed domain documents, local indexes, persistence, and
|
|
file-compatible cache storage.
|
|
- `GameCult.Networking` for CultNet schema-v0 wire messages, native RUDP,
|
|
transport adapters, and secure client/server sessions.
|
|
|
|
The intent is direct: a game can treat a mesh of clients and servers as one
|
|
reactive database for persistent state, prediction-friendly input state, and
|
|
simulation observations.
|
|
|
|
## First Node
|
|
|
|
```csharp
|
|
using GameCult.Caching;
|
|
using GameCult.Mesh;
|
|
using GameCult.Networking;
|
|
|
|
// CounterState is the typed document defined in the getting-started tutorial.
|
|
var documents = new CultNetDocumentRegistry()
|
|
.Register(CultNetDocumentBinding.ForDocument<CounterState>());
|
|
using var node = await CultMesh.StartNodeAsync("world.cc", new CultMeshNodeOptions
|
|
{
|
|
DatabaseOptions = new CultNetDatabaseOptions
|
|
{
|
|
RuntimeId = "counter-provider",
|
|
DocumentRegistry = documents
|
|
}
|
|
});
|
|
|
|
var key = new CultRecordKey("counter:main");
|
|
await node.Database.PutAsync(key, new CounterState { CounterId = key.Value });
|
|
var counter = await node.Database.GetAsync<CounterState>(key);
|
|
```
|
|
|
|
If you want one inspectable path that starts with a durable local cache file and
|
|
ends with a typed read/write through the public CultMesh surface, see
|
|
[docs/durable-node-quickstart.md](docs/durable-node-quickstart.md).
|
|
If you want the lower-level typed-to-raw handoff that CultNet moves across the
|
|
wire, see [docs/typed-document-path.md](docs/typed-document-path.md).
|
|
|
|
## Eve / CultUI Surfaces
|
|
|
|
CultMesh supplies the typed state and operation binding descriptors used by Eve.
|
|
The renderer-neutral `GameCult.Eve.Surface` package in the Eve repository owns
|
|
the portable surface document and fluent builder. Add that package when a daemon
|
|
publishes UI; do not add EveUnity to the provider. The emitted document carries
|
|
typed CultMesh operation bindings, state bindings, style tokens, and embedded
|
|
surface slots while remaining independent of any lowering runtime.
|
|
|
|
```csharp
|
|
using GameCult.Eve.Surface;
|
|
using GameCult.Mesh;
|
|
|
|
var menu = EveSurface.Create("aetheria.main_menu.root")
|
|
.Provider("aetheria", "game.menu")
|
|
.TitleSubtitle("AETHERIA", "TERMINUS")
|
|
.ButtonColumn("aetheria.main_menu.root.actions", actions => actions
|
|
.Button("Continue", "aetheria.main_menu.root.continue")
|
|
.Button("New Game", "aetheria.main_menu.root.new_game")
|
|
.Button("Settings", "aetheria.main_menu.root.show_settings")
|
|
.Button("Quit", "aetheria.main_menu.root.quit"))
|
|
.Style("font.title.family", "Montserrat")
|
|
.Style("font.body.family", "Ubuntu")
|
|
.Build();
|
|
```
|
|
|
|
Forms accept typed CultMesh descriptors at authoring time and flatten them into
|
|
MessagePack-safe Eve records in the document:
|
|
|
|
```csharp
|
|
var setName = CultMesh.OperationBinding(
|
|
"settings.player.name.set",
|
|
"Set Player Name",
|
|
"gamecult.settings.name.v1");
|
|
|
|
var playerName = new CultMeshStateBindingDescriptor(
|
|
"value",
|
|
"player.settings.name",
|
|
"global:aetheria.player_settings.v1",
|
|
"gamecult.aetheria.player_settings.v1");
|
|
|
|
var settings = EveSurface.Create("aetheria.main_menu.player_settings")
|
|
.Form("aetheria.main_menu.player_settings.form", form => form
|
|
.Text("Name", "Meta", setName, playerName))
|
|
.Build();
|
|
```
|
|
|
|
Nested UI is expressed with `surface.slot` components and
|
|
`EveEmbeddedDocumentSlot` records. A runtime lowers the parent surface, resolves
|
|
the child document through CultMesh, and renders the embedded document without
|
|
the renderer becoming the owner of that UI.
|
|
|
|
Enable durable authoritative shard logs when a node should serve replica
|
|
catch-up after restart:
|
|
|
|
```csharp
|
|
using var node = await CultMesh.StartNodeAsync("world.cc", new CultMeshNodeOptions
|
|
{
|
|
EnableDurableShardLogs = true
|
|
});
|
|
```
|
|
|
|
If `ShardLogPath` is omitted, CultMesh stores logs beside the cache under
|
|
`world.cultmesh/shard-logs`.
|
|
|
|
## Distributed CDN
|
|
|
|
CultMesh can distribute signed, content-addressed game assets through the same
|
|
typed document lane as gameplay state. Asset manifests, chunks, and portable
|
|
entity prefab packages are CultCache documents that can be replicated by
|
|
CultNet snapshots, shard logs, central servers, LAN peers, or opted-out-capable
|
|
internet peer caches.
|
|
|
|
This is the canonical asset pipeline target for Aetheria. Brokkr-authored
|
|
Blender prefab snapshots deploy into `CultMeshEntityPrefabPackage` documents
|
|
that reference CDN artifact manifests for meshes, textures, materials, and
|
|
other payloads. Unity and TypeScript clients should unpack the same package
|
|
graph rather than maintaining separate Unity-prefab and Electron asset formats.
|
|
|
|
See [docs/distributed-cdn.md](docs/distributed-cdn.md) for document shapes,
|
|
publish/read helpers, peer asset sharing policy, and the Brokkr prefab flow.
|
|
|
|
## Verses
|
|
|
|
A Verse is a rule-bearing consensus graph. Aetheria Main can be a
|
|
cloud-authoritative Verse backed by GameCult regional simulators. A modded
|
|
community branch can be transport-compatible with Aetheria Main while declaring
|
|
different rules, required plugins, and authority policy. A peer-hosted game can
|
|
choose a peer-to-peer authority model.
|
|
|
|
```csharp
|
|
var rulesHash = CultMeshVerseDescriptor.ComputeRulesHash(
|
|
"aetheria",
|
|
"rules:v1",
|
|
"plugins:vanilla");
|
|
|
|
var aetheria = new CultMeshVerseDescriptor(
|
|
"aetheria-main",
|
|
"Aetheria",
|
|
CultMeshVerseAuthorityModel.OperatorCluster,
|
|
new CultMeshVerseCompatibility("cultmesh.v0", rulesHash),
|
|
discoveryEndpoints: ["cultmesh://us-east.aetheria.gamecult.net:3075"]);
|
|
|
|
using var catalog = CultMesh.CreateVerseCatalog();
|
|
catalog.Upsert(aetheria);
|
|
|
|
using var discovery = CultMesh.ServeVerseCatalog(node, catalog);
|
|
```
|
|
|
|
Peers can request Verse catalogs over schema-v0 with
|
|
`cultmesh.verse_catalog_request.v0` and receive transport-filtered
|
|
`cultmesh.verse_catalog_response.v0` descriptors.
|
|
|
|
## Peer Exchange
|
|
|
|
Verse discovery tells a node which consensus graph exists. Peer exchange tells
|
|
it who else is already in that graph.
|
|
|
|
```csharp
|
|
using var peers = CultMesh.CreatePeerCatalog();
|
|
peers.Upsert(new CultMeshPeerCard(
|
|
"gc-us-east-1",
|
|
"aetheria-main",
|
|
["cultnet://us-east.aetheria.gamecult.net:3075"],
|
|
roles: [CultMeshPeerRoles.Discovery, CultMeshPeerRoles.ShardPrimary],
|
|
shardIds: ["players-us-east"],
|
|
authorityLeaseId: "lease:players-us-east:42"));
|
|
|
|
using var exchange = CultMesh.ServePeerExchange(node, peers);
|
|
```
|
|
|
|
Peer cards are contact candidates, not authority by themselves. Public Verses
|
|
should validate authority leases or signatures before trusting a peer for
|
|
committed state.
|
|
|
|
```csharp
|
|
var leases = CultMesh.CreateAuthorityLeaseCatalog();
|
|
leases.Upsert(new CultMeshAuthorityLease(
|
|
"lease:players-us-east:42",
|
|
"aetheria-main",
|
|
"gc-us-east-1",
|
|
[CultMeshPeerRoles.ShardPrimary],
|
|
["players-us-east"],
|
|
"gamecult-operator",
|
|
DateTimeOffset.UtcNow.AddMinutes(-1),
|
|
DateTimeOffset.UtcNow.AddMinutes(10),
|
|
signature: "operator-signature"));
|
|
```
|
|
|
|
`CultMeshPeerCatalog.FindAuthorized(...)` and `FirstAuthorized(...)` compose
|
|
peer lookup with the lease catalog, so contact gossip stays separate from
|
|
trust. The branded RUDP helpers use the same boundary:
|
|
|
|
```csharp
|
|
using var rudpClient = CultMesh.ConnectRudpClientForAuthorizedPeer(
|
|
"aetheria-client",
|
|
connectionId: 0x10203040,
|
|
peers,
|
|
leases,
|
|
"aetheria-main",
|
|
CultMeshPeerRoles.ShardPrimary,
|
|
shardId: "players-us-east");
|
|
```
|
|
|
|
`CreateRudpServer(...)`, `ParseRudpEndpoint(...)`,
|
|
`ConnectRudpClient(...)`, and `ConnectRudpClientForPeer(...)` delegate to the
|
|
CultNet RUDP socket transport and return the same schema-message-capable
|
|
transport after handshake. `CreateRudpClient(...)` and
|
|
`CreateRudpClientForPeer(...)` remain available when a caller intentionally owns
|
|
the handshake and polling loop. CultMesh owns peer/authority ergonomics;
|
|
CultNet owns packet reliability, resend pressure, and channel semantics.
|
|
|
|
Clients can fetch more peers from known peers:
|
|
|
|
```csharp
|
|
var exchangeClient = CultMesh.CreatePeerExchangeClient();
|
|
await exchangeClient.DiscoverAsync(
|
|
peers,
|
|
aetheria.DiscoveryEndpoints,
|
|
"aetheria-main",
|
|
roles: [CultMeshPeerRoles.ReadReplica]);
|
|
```
|
|
|
|
## Client-Side Prediction
|
|
|
|
CultMesh lets a runtime declare which input documents it may author
|
|
optimistically. The local cache updates immediately, game simulation can proceed
|
|
against that predicted state, and the authoritative shard log later reconciles
|
|
the record.
|
|
|
|
```csharp
|
|
var db = node.Database;
|
|
var input = CultMesh.Document<PlayerInput>(db, inputKey, verse);
|
|
|
|
await input.SubmitPredictionAsync(inputState);
|
|
|
|
using var sub = input.Watch()
|
|
.Subscribe(latest =>
|
|
{
|
|
ReconcilePrediction(latest);
|
|
});
|
|
```
|
|
|
|
Read-only CultCache publications can be lifted into the same document handle
|
|
shape without local store plumbing:
|
|
|
|
```csharp
|
|
var health = CultMesh.DocumentFromSingleFile<DaemonHealth>(
|
|
"daemon-publication.cc",
|
|
new CultRecordKey("daemon:aetheria.health.v1"),
|
|
verse);
|
|
|
|
using var healthSub = health.Watch().Subscribe(RenderHealth);
|
|
var latestHealth = await health.LatestAsync();
|
|
```
|
|
|
|
`DocumentFromStore<T>()` accepts any `CacheBackingStore`; both helpers pull,
|
|
decode, resolve persisted schema metadata, and poll for updates behind the
|
|
`CultMeshDocumentHandle<T>` surface.
|
|
Use `DocumentFromPublication<T>()` when the caller should provide a configured
|
|
source instead of branching between local files, backing stores, and remote
|
|
CultNet snapshots itself.
|
|
|
|
When the remote snapshot should hydrate a local node, use the direct sync
|
|
facade and keep the rest of the code on local typed handles:
|
|
|
|
```csharp
|
|
var syncedHealth = await CultMesh.SyncDocumentFromPeerSnapshotAsync<DaemonHealthUi>(
|
|
node,
|
|
"cultnet://daemon.local:3075",
|
|
"daemon:aetheria.health.v1",
|
|
new CultMeshSnapshotEndpointOptions { DocumentRegistry = daemonRegistry });
|
|
|
|
var localHealth = await node
|
|
.Document<DaemonHealthUi>("daemon:aetheria.health.v1", verse)
|
|
.LatestAsync();
|
|
```
|
|
|
|
## Game Session
|
|
|
|
`CultMeshGameSession` wires the common gameplay loop: local catalogs,
|
|
observation hub, observation server, peer exchange, Verse discovery, prediction,
|
|
and quorum fact commits.
|
|
|
|
```csharp
|
|
using var session = CultMesh.CreateGameSession(node, new CultMeshGameSessionOptions
|
|
{
|
|
ConsensusOptions = new CultNetSimulationConsensusOptions
|
|
{
|
|
MinimumWitnesses = 2,
|
|
QuorumRatio = 0.66d
|
|
}
|
|
});
|
|
|
|
await session.PredictAsync(inputKey, inputState);
|
|
|
|
var committed = await session.SubmitAndCommitAsync(new CultNetSimulationObservation
|
|
{
|
|
WitnessRuntimeId = "watcher-1",
|
|
ShardId = "arena",
|
|
ShardEpoch = 4,
|
|
Frame = 100,
|
|
SubjectId = "bob",
|
|
ClaimKind = "hit",
|
|
ClaimHash = CultNetSimulationObservation.ComputeClaimHash("hit", "alice", "bob", "frame:100")
|
|
});
|
|
```
|
|
|
|
## Managed Documents
|
|
|
|
CultMesh presents distributed state as the same managed POCO document shape as
|
|
CultCache. Local edits commit through shard authority; remote shard-log or
|
|
network events update the same watched value.
|
|
|
|
```csharp
|
|
using GameCult.Caching;
|
|
using GameCult.Mesh;
|
|
|
|
var player = node.Database.Document<PlayerTransform>(new CultRecordKey("player:alice"));
|
|
|
|
using var subscription = player.Watch().Subscribe(current => Render(current));
|
|
|
|
await player.ReplaceAsync(new PlayerTransform
|
|
{
|
|
Name = "Alice",
|
|
PositionX = 4,
|
|
Health = 100
|
|
});
|
|
|
|
player.Value!.Health = 90;
|
|
await player.CommitAsync();
|
|
```
|
|
|
|
Remote publications use the same document handle shape:
|
|
|
|
```csharp
|
|
var health = CultMesh.DocumentFromPeerSnapshot<DaemonHealthDocument>(
|
|
"cultnet://daemon.local:3075",
|
|
"daemon:aetheria.health.v1",
|
|
verse);
|
|
|
|
var latest = await health.LatestAsync();
|
|
```
|
|
|
|
The physical cache table remains SoA for CPU-local scans:
|
|
|
|
```csharp
|
|
Span<float> x = node.Cache.Soa<PlayerTransform>()
|
|
.Column<float>(nameof(PlayerTransform.PositionX))
|
|
.Span;
|
|
```
|
|
|
|
## Witness Consensus
|
|
|
|
CultMesh nodes can publish observations about simulation facts: shard epoch,
|
|
frame, subject, claim kind, and claim hash. `CultNetSimulationConsensus`
|
|
aggregates those observations deterministically into candidate facts with
|
|
support weight, total observed weight, confidence, and quorum status.
|
|
|
|
Those candidates are not committed world state by themselves. They are the fast
|
|
mesh opinion layer that can feed an authoritative shard commit path. Once a
|
|
candidate has quorum, a shard-authoritative node can commit it as a
|
|
`CultMeshSimulationFact`.
|
|
|
|
```csharp
|
|
var committer = CultMesh.CreateSimulationFactCommitter(node.Database);
|
|
await committer.CommitAsync(candidate);
|
|
```
|
|
|
|
The central server does not need to decide what every witness saw. It only
|
|
needs to accept the quorum result through the shard log.
|
|
|
|
## Durable Catch-Up
|
|
|
|
CultMesh can persist authoritative shard logs through
|
|
`ICultNetShardMutationLogStore`. The included file store writes one MessagePack
|
|
log per shard, so a restarted primary can still serve replica catch-up without
|
|
depending on warm process memory. When retained log history is compacted, stale
|
|
replicas get an explicit resync requirement instead of a partial log pretending
|
|
to be enough. The replicator can answer that by fetching a shard-bounded
|
|
snapshot, replacing the local shard view, and advancing its cursor to the
|
|
snapshot's represented log sequence.
|
|
|
|
## Streaming Mode
|
|
|
|
CultMesh streaming mode is the low-latency body path for Mimir, Fensalir, Eve,
|
|
and other realtime runtimes. CultCache and CultNet own typed state, discovery,
|
|
stream descriptors, negotiations, and frame handles. The frame bodies stay in
|
|
runtime-native storage: shared D3D12 textures, shared memory rings, platform GPU
|
|
handles, DMA buffers, or paged CultCache fallbacks when a zero-copy transport is
|
|
not available.
|
|
|
|
For entity SoA and other CPU-visible hot state, subscribe to the exact control
|
|
records and logical bodies the lowerer consumes. The subscription advertises
|
|
the transports the runtime can import; the provider's demand tracker selects
|
|
the cheapest mutually usable representation. A same-machine consumer normally
|
|
receives one shared-memory bootstrap descriptor, then observes later commits
|
|
through the mapped ring header. Per-frame CultNet documents are neither needed
|
|
nor desirable.
|
|
|
|
The mapped ring uses a named memory map on Windows and a capability-named,
|
|
publisher-owned temporary backing file on Unix. This distinction belongs to
|
|
the CultMesh adapter; providers and consumers use the same descriptor/cursor
|
|
contract on both platforms.
|
|
|
|
```csharp
|
|
using var entities = await CultMesh.SubscribeLiveBodyAsync(
|
|
subscriptions,
|
|
bodyResolver,
|
|
new CultMeshLiveBodySubscription(
|
|
"pilot-entities",
|
|
"eve-unity",
|
|
"eve:entity-soa:aetheria.daemon:pilot"));
|
|
|
|
using var input = await subscriptions.SubscribeLiveValueAsync<EveInputCapabilityDocument>(
|
|
"pilot-input",
|
|
"eve:input:aetheria.daemon:pilot");
|
|
|
|
// Open after each descriptor change. CultMesh chooses shared memory, shared-file
|
|
// mapping, or network from observed locality and the resolver's real adapters.
|
|
if (entities.HasValue && input.HasValue)
|
|
{
|
|
using var frame = entities.OpenCurrentReadOnly().Lease;
|
|
Present(frame, input.Current);
|
|
}
|
|
```
|
|
|
|
`CultMeshLiveBody` watches only the small latest-publication record and declares
|
|
the logical body demand. It never routes the body through the subscription
|
|
snapshot. `CultNetLiveValue<T>` does the corresponding job for exact scalar or
|
|
small structured state without creating a renderer-owned replica cache. Both
|
|
subscriptions may begin before the provider's first publication: `HasValue`
|
|
remains false while the retained demand stays active, then the first typed value
|
|
arrives through the normal reactive change path.
|
|
|
|
Snapshots remain bootstrap and recovery machinery. Reactive document changes
|
|
carry small control state; mapped or negotiated network bodies carry hot data.
|
|
|
|
```csharp
|
|
using GameCult.Mesh;
|
|
|
|
var streams = CultMesh.CreateStreamCatalog();
|
|
|
|
streams.Declare(new CultMeshStreamDescriptor(
|
|
"mimir:kiyo-pro:rgba",
|
|
"mimir-live",
|
|
"starfire",
|
|
CultMeshStreamKind.Video,
|
|
new CultMeshStreamClock("mimir:clock", "kiyo-pro", sampleRate: 90_000, confidence: 0.92),
|
|
new[]
|
|
{
|
|
CultMeshStreamBodyTransport.SharedD3D12Texture,
|
|
CultMeshStreamBodyTransport.SharedMemory,
|
|
CultMeshStreamBodyTransport.CultCachePage
|
|
},
|
|
video: new CultMeshVideoStreamFormat(1920, 1080, "rgba8", framesPerSecond: 60),
|
|
maxInFlightFrames: 4));
|
|
|
|
var fensalir = new CultMeshStreamConsumerProfile(
|
|
"fensalir",
|
|
"mimir-live",
|
|
new[] { CultMeshStreamBodyTransport.SharedD3D12Texture },
|
|
acceptedKinds: new[] { CultMeshStreamKind.Video },
|
|
canImportGpuHandles: true);
|
|
|
|
var negotiation = streams.Negotiate("mimir:kiyo-pro:rgba", fensalir);
|
|
```
|
|
|
|
For CPU-visible data, `CultMeshSharedMemoryFrameRing` preallocates fixed slots
|
|
and hands producers writable leases. Consumers hold read leases so the producer
|
|
does not overwrite a visible frame. `TryPublishCopy` exists for unavoidable
|
|
fallbacks and increments copy telemetry; the normal hot path writes directly
|
|
into a lease and commits a `CultMeshStreamFrameHandle`.
|
|
|
|
```csharp
|
|
streams.Declare(new CultMeshStreamDescriptor(
|
|
"mimir:leap:depth",
|
|
"mimir-live",
|
|
"starfire",
|
|
CultMeshStreamKind.Tensor,
|
|
new CultMeshStreamClock("mimir:clock", "leap"),
|
|
new[] { CultMeshStreamBodyTransport.SharedMemory }));
|
|
|
|
using var ring = streams.CreateSharedMemoryRing("mimir:leap:depth", slotCount: 4, slotByteLength: 640 * 480 * 2);
|
|
|
|
if (ring.TryAcquireWriteSlot(out var write))
|
|
{
|
|
FillDepthFrame(write.Span);
|
|
var frame = ring.CommitWriteSlot(write, timestampNs: GetVerseTimeNs(), byteLength: 640 * 480 * 2);
|
|
streams.PublishFrame(frame);
|
|
}
|
|
```
|
|
|
|
The invariant is simple: typed mesh state describes and synchronizes streams;
|
|
hot frame bodies remain in the cheapest storage both endpoints can share.
|
|
|
|
## Documentation
|
|
|
|
- [Public API](docs/public-api.md)
|
|
- [Durable Node Quickstart](docs/durable-node-quickstart.md)
|
|
- [Typed Document Path](docs/typed-document-path.md)
|
|
- [Distributed CDN Documents](docs/distributed-cdn.md)
|
|
- [Research Notes](docs/research.md)
|
|
- [Verse Model](docs/verses.md)
|