diff --git a/Directory.Build.props b/Directory.Build.props
index e06af334..e8bad6c0 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,7 +14,7 @@
MIT
© Meta Vens and contributors
true
- 1.0.24
+ 1.0.25
true
diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs
index d19063ef..34b31474 100644
--- a/src/GameCult.Mesh/CultMesh.cs
+++ b/src/GameCult.Mesh/CultMesh.cs
@@ -2859,7 +2859,8 @@ namespace GameCult.Mesh
public static Task> SubscribeHotBodyAsync(
CultNetDatabaseSubscriptionClient subscriptions,
IEnumerable supportedBodyTransports,
- CultMeshHotBodySubscription subscription)
+ CultMeshHotBodySubscription subscription,
+ CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
{
if (subscriptions == null) throw new ArgumentNullException(nameof(subscriptions));
if (supportedBodyTransports == null) throw new ArgumentNullException(nameof(supportedBodyTransports));
@@ -2873,17 +2874,19 @@ namespace GameCult.Mesh
schemaIds: subscription.SchemaIds,
consumerRuntimeId: subscription.ConsumerRuntimeId,
bodyIds: new[] { subscription.BodyId },
- supportedBodyTransports: transports.Select(value => value.ToString()));
+ supportedBodyTransports: transports.Select(value => value.ToString()),
+ deliveryMode: deliveryMode);
}
/// Opens an exact hot-body subscription using a resolver's readable planes.
public static Task> SubscribeHotBodyAsync(
CultNetDatabaseSubscriptionClient subscriptions,
CultMeshBodyPublicationResolver bodyResolver,
- CultMeshHotBodySubscription subscription)
+ CultMeshHotBodySubscription subscription,
+ CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
{
if (bodyResolver == null) throw new ArgumentNullException(nameof(bodyResolver));
- return SubscribeHotBodyAsync(subscriptions, bodyResolver.SupportedTransports, subscription);
+ return SubscribeHotBodyAsync(subscriptions, bodyResolver.SupportedTransports, subscription, deliveryMode);
}
/// Resolves a CultMesh URI or RUDP endpoint into a concrete RUDP endpoint.
diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md
index 8b183dc6..650ab7fd 100644
--- a/src/GameCult.Mesh/docs/reliability-migration-plan.md
+++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md
@@ -926,6 +926,37 @@ can actually open, ordered shared-memory first; the server combines those
capabilities with observed peer locality. Applications do not hand-assemble
transport-name arrays or request one snapshot per frame.
+### Reactive document delivery body map
+
+**Owner:** the subscription declaration owns whether a selected typed document
+is durable replica state or an ephemeral live value. The provider still owns
+the value; the transport and renderer own neither its persistence nor meaning.
+
+**Inputs:** exact record/schema filters and an explicit
+`CultNetDatabaseSubscriptionDeliveryMode`. `ReplicateToCache` materializes
+bootstrap and durable state before notification. `Live` validates and decodes
+the same typed payload, then notifies the subscriber without a replica write.
+
+**Outputs:** both modes produce the same typed initial values and change event.
+Only durable replication mutates the consumer's CultCache.
+
+**Derived state:** renderer-local presentation and reactive variables are
+derived from live notifications. They are not database records merely because
+CultNet carried them.
+
+**Forbidden writers:** renderers and products cannot add polling, keepalive
+writes, or broad snapshots to repair callback latency. Ephemeral receipts,
+input facts, and frame-layout notifications cannot silently become disk-backed
+replica history.
+
+**Shared paths:** exact subscription filters, wire schema validation,
+deserialization, unsubscribe, disconnect cleanup, and body-demand negotiation
+are identical in both modes. Only the final materialization decision differs.
+
+**Cut line:** `CultNetDatabaseSubscriptionClient` no longer requires every
+received document to complete a CultCache write before the consumer may see it.
+Durable documents retain the existing replica path; live values bypass it.
+
**Cut line:** local frame publication writes directly into a mapped write lease
and commits its descriptor. The compatibility byte-array publisher is copy-in
only. The current remote frame representation may still use the verified CDN
diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs
index d0a545c0..4c7770ef 100644
--- a/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs
+++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionClient.cs
@@ -9,6 +9,15 @@ using GameCult.Caching;
namespace GameCult.Networking
{
+ /// Controls whether subscription payloads become replica state or remain ephemeral typed events.
+ public enum CultNetDatabaseSubscriptionDeliveryMode
+ {
+ /// Decode each payload and write it into the local CultCache before notifying observers.
+ ReplicateToCache,
+ /// Decode each payload and notify observers without writing replica state.
+ Live
+ }
+
///
/// Replicates selected remote database records into a local typed CultCache over one retained session.
///
@@ -17,7 +26,9 @@ namespace GameCult.Networking
private readonly ICultNetSchemaClient _client;
private readonly CultCache _cache;
private readonly CultNetDocumentRegistry _documents;
- private readonly ConcurrentDictionary>> _initialSnapshots = new();
+ private readonly ConcurrentDictionary _initialSnapshots = new();
+ private readonly ConcurrentDictionary _deliveryModes =
+ new(StringComparer.Ordinal);
private readonly object _queueLock = new();
private Task _queue = Task.CompletedTask;
private bool _disposed;
@@ -55,7 +66,8 @@ namespace GameCult.Networking
CancellationToken cancellationToken = default,
string? consumerRuntimeId = null,
IEnumerable? bodyIds = null,
- IEnumerable? supportedBodyTransports = null)
+ IEnumerable? supportedBodyTransports = null,
+ CultNetDatabaseSubscriptionDeliveryMode deliveryMode = CultNetDatabaseSubscriptionDeliveryMode.ReplicateToCache)
{
if (_disposed) throw new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient));
if (!_client.Connected) throw new InvalidOperationException("CultNet schema client must be connected before subscribing.");
@@ -63,7 +75,7 @@ namespace GameCult.Networking
var messageId = Guid.NewGuid().ToString("N");
var completion = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously);
- if (!_initialSnapshots.TryAdd(messageId, completion))
+ if (!_initialSnapshots.TryAdd(messageId, new PendingSubscription(subscriptionId, deliveryMode, completion)))
throw new InvalidOperationException($"Duplicate database subscription message id '{messageId}'.");
CancellationTokenRegistration cancellation = default;
@@ -72,7 +84,7 @@ namespace GameCult.Networking
cancellation = cancellationToken.Register(() =>
{
if (_initialSnapshots.TryRemove(messageId, out var pending))
- pending.TrySetCanceled(cancellationToken);
+ pending.Completion.TrySetCanceled(cancellationToken);
});
_ = completion.Task.ContinueWith(
_ => cancellation.Dispose(),
@@ -101,7 +113,7 @@ namespace GameCult.Networking
catch
{
if (_initialSnapshots.TryRemove(messageId, out var pending))
- pending.TrySetException(new InvalidOperationException("CultNet subscription request could not be sent."));
+ pending.Completion.TrySetException(new InvalidOperationException("CultNet subscription request could not be sent."));
throw;
}
return completion.Task;
@@ -112,6 +124,7 @@ namespace GameCult.Networking
{
if (_disposed) return;
if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription id is required.", nameof(subscriptionId));
+ _deliveryModes.TryRemove(subscriptionId, out _);
_client.SendCultNet(new CultNetDatabaseUnsubscribeMessage
{
MessageId = Guid.NewGuid().ToString("N"),
@@ -123,9 +136,10 @@ namespace GameCult.Networking
{
if (_disposed) return;
_disposed = true;
- foreach (var completion in _initialSnapshots.Values)
- completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)));
+ foreach (var pending in _initialSnapshots.Values)
+ pending.Completion.TrySetException(new ObjectDisposedException(nameof(CultNetDatabaseSubscriptionClient)));
_initialSnapshots.Clear();
+ _deliveryModes.Clear();
_client.Dispose();
}
@@ -133,15 +147,24 @@ namespace GameCult.Networking
{
Enqueue(async () =>
{
- if (!_initialSnapshots.TryRemove(message.MessageId, out var completion)) return;
+ if (!_initialSnapshots.TryRemove(message.MessageId, out var pending)) return;
try
{
- var applied = await _documents.ApplyRawSnapshotResponseAsync(_cache, message).ConfigureAwait(false);
- completion.TrySetResult(applied);
+ IReadOnlyList