diff --git a/docs/eve-dashboard.md b/docs/eve-dashboard.md
index bc5248e..37a97dd 100644
--- a/docs/eve-dashboard.md
+++ b/docs/eve-dashboard.md
@@ -32,8 +32,9 @@ own final composition or run remote UI code.
- CultMesh line: dashboard manifests and states now have typed
`mimir.eve_dashboard_manifest.v1` and `mimir.eve_dashboard_state.v1`
document shapes. Semantic provider identities use
- `asgard.starfire.mimir/...`; the live iPad lane remains a WebSocket bridge
- until the dashboard broker subscribes over CultNet reliable UDP directly.
+ `asgard.starfire.mimir/...`; the broker publishes dashboard state through
+ CultNet reliable UDP for native subscribers. WebSocket endpoints remain
+ renderer compatibility bridges.
## Runtime
@@ -50,6 +51,14 @@ asgard.starfire.mimir/eve/tui
asgard.starfire.mimir/eve/gui
```
+Native CultNet route for typed dashboard state:
+
+```text
+cultnet://192.168.1.66:3075
+schema: mimir.eve_dashboard_state.v1
+record: eve.dashboard.broker
+```
+
EveCanvas currently connects through this bridge route:
```text
diff --git a/src/Mimir.EveDashboard/Mimir.EveDashboard.csproj b/src/Mimir.EveDashboard/Mimir.EveDashboard.csproj
index 2333127..e706686 100644
--- a/src/Mimir.EveDashboard/Mimir.EveDashboard.csproj
+++ b/src/Mimir.EveDashboard/Mimir.EveDashboard.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/Mimir.EveDashboard/Program.cs b/src/Mimir.EveDashboard/Program.cs
index 1158acc..20ce8e7 100644
--- a/src/Mimir.EveDashboard/Program.cs
+++ b/src/Mimir.EveDashboard/Program.cs
@@ -6,15 +6,21 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
+using GameCult.Caching;
+using GameCult.Mesh;
+using GameCult.Networking;
using MessagePack;
using Mimir.Runtime.Synchronization;
var port = ParseInt(args, "--port", 8795);
+var cultNetPort = ParseInt(args, "--cultnet-port", 3075);
+var cultNetCachePath = ParseString(args, "--cultnet-cache", @"E:\Projects\Mimir\state\eve-dashboard-cultnet.ccmp");
var voidBotSwarmStatePath = ParseString(args, "--voidbot-swarm-state", @"E:\Projects\VoidBot\.voidbot\status\swarm-state.json");
var mimirTelemetryLogPath = ParseString(args, "--mimir-telemetry-log", "");
var mimirObservationLogPath = ParseString(args, "--mimir-observation-log", @"E:\Projects\Mimir\artifacts\runtime\periwinkle-cultmesh-sensors.out.log");
var providers = EveDashboardProviderCatalog.Create(ParseProviderSpecs(args), voidBotSwarmStatePath, mimirTelemetryLogPath, mimirObservationLogPath);
-using var server = new EveDashboardServer(port, providers);
+using var cultNetPublisher = await EveDashboardCultNetPublisher.StartAsync(cultNetCachePath, cultNetPort).ConfigureAwait(false);
+using var server = new EveDashboardServer(port, providers, cultNetPublisher);
await server.RunAsync();
static int ParseInt(IReadOnlyList args, string name, int fallback)
@@ -59,7 +65,7 @@ static string ParseString(IReadOnlyList args, string name, string fallba
return fallback;
}
-internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog providers) : IDisposable
+internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog providers, EveDashboardCultNetPublisher cultNetPublisher) : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -76,6 +82,7 @@ internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog p
{
listener.Start();
Console.WriteLine($"Mimir Eve dashboard broker listening on ws://0.0.0.0:{port}/eve/deck");
+ Console.WriteLine($"Mimir Eve dashboard CultNet publisher listening on cultnet://0.0.0.0:{cultNetPublisher.Port}");
Console.WriteLine($"Compatibility endpoint remains ws://0.0.0.0:{port}/eve/dashboard");
_ = Task.Run(BroadcastHeartbeatAsync);
while (!stopping.IsCancellationRequested)
@@ -90,6 +97,7 @@ internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog p
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (!stopping.IsCancellationRequested && await timer.WaitForNextTickAsync(stopping.Token).ConfigureAwait(false))
{
+ await PublishCultNetStateAsync().ConfigureAwait(false);
if (clients.IsEmpty)
{
continue;
@@ -307,37 +315,45 @@ internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog p
var state = providers.Get(activeProviderId).State;
if (socket.CultMeshBinary)
{
- var document = new MimirEveDashboardStateDocument(
- state.ProviderId,
- state.Title,
- state.Version,
- state.UpdatedAt.ToString("O"),
- state.SelectedNodeId,
- state.LutPreset,
- state.Nodes.Select(static node => new MimirEveDashboardNodeSnapshot(
- node.Id,
- node.Label,
- node.Kind,
- node.Visible,
- node.X,
- node.Y,
- node.Z,
- node.Rotation,
- node.Scale,
- node.Width,
- node.Height,
- node.Health,
- node.ProviderId,
- node.Command,
- node.Endpoint)).ToArray(),
- ToCultMeshSurface(state.Surface));
- await SendBinaryFrameAsync(socket.Stream, MessagePackSerializer.Serialize(document)).ConfigureAwait(false);
+ await SendBinaryFrameAsync(socket.Stream, MessagePackSerializer.Serialize(ToCultMeshState(state))).ConfigureAwait(false);
return;
}
await SendTextFrameAsync(socket.Stream, JsonSerializer.Serialize(state, JsonOptions)).ConfigureAwait(false);
}
+ private Task PublishCultNetStateAsync()
+ {
+ var state = providers.Get(activeProviderId).State;
+ return cultNetPublisher.PublishAsync(ToCultMeshState(state), stopping.Token);
+ }
+
+ private static MimirEveDashboardStateDocument ToCultMeshState(DashboardState state) =>
+ new(
+ state.ProviderId,
+ state.Title,
+ state.Version,
+ state.UpdatedAt.ToString("O"),
+ state.SelectedNodeId,
+ state.LutPreset,
+ state.Nodes.Select(static node => new MimirEveDashboardNodeSnapshot(
+ node.Id,
+ node.Label,
+ node.Kind,
+ node.Visible,
+ node.X,
+ node.Y,
+ node.Z,
+ node.Rotation,
+ node.Scale,
+ node.Width,
+ node.Height,
+ node.Health,
+ node.ProviderId,
+ node.Command,
+ node.Endpoint)).ToArray(),
+ ToCultMeshSurface(state.Surface));
+
private static MimirEveDashboardSurfaceSnapshot? ToCultMeshSurface(DashboardSurface? surface)
{
if (surface == null)
@@ -611,6 +627,56 @@ internal sealed class EveDashboardServer(int port, EveDashboardProviderCatalog p
}
}
+internal sealed class EveDashboardCultNetPublisher : IDisposable
+{
+ private static readonly CultRecordKey StateRecordKey = new("eve.dashboard.broker");
+ private readonly CultMeshNode node;
+
+ private EveDashboardCultNetPublisher(CultMeshNode node, int port)
+ {
+ this.node = node;
+ Port = port;
+ }
+
+ public int Port { get; }
+
+ public static async Task StartAsync(string cachePath, int port)
+ {
+ if (port != 3075)
+ {
+ throw new ArgumentOutOfRangeException(nameof(port), port, "CultNet Server currently listens on the runtime default port 3075.");
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(cachePath)) ?? ".");
+ var documents = new CultNetDocumentRegistry()
+ .Register(CultNetDocumentBinding.ForDocument());
+ var node = await CultMesh.CreateNodeAsync(cachePath, new CultMeshNodeOptions
+ {
+ StartServer = true,
+ DatabaseOptions = new CultNetDatabaseOptions
+ {
+ RuntimeId = "mimir.eve-dashboard",
+ DocumentRegistry = documents,
+ },
+ }).ConfigureAwait(false);
+
+ return new EveDashboardCultNetPublisher(node, port);
+ }
+
+ public async Task PublishAsync(MimirEveDashboardStateDocument document, CancellationToken stopping)
+ {
+ await node.Database.PutAsync(new CultRecordKey(document.ProviderId), document).ConfigureAwait(false);
+ if (!string.Equals(document.ProviderId, StateRecordKey.Value, StringComparison.Ordinal))
+ {
+ await node.Database.PutAsync(StateRecordKey, document).ConfigureAwait(false);
+ }
+
+ await node.FlushAsync(soft: true).ConfigureAwait(false);
+ }
+
+ public void Dispose() => node.Dispose();
+}
+
internal sealed class EveDashboardProviderCatalog
{
private readonly Dictionary providers;