diff --git a/src/Ymir.Core/Ymir.Core.csproj b/src/Ymir.Core/Ymir.Core.csproj
index 02139b9..241aa28 100644
--- a/src/Ymir.Core/Ymir.Core.csproj
+++ b/src/Ymir.Core/Ymir.Core.csproj
@@ -2,11 +2,13 @@
$(MSBuildThisFileDirectory)..\..\..\CultLib
+ $(MSBuildThisFileDirectory)..\..\..\EveUnity
+
diff --git a/src/Ymir.Core/YmirServicePublicationDocument.cs b/src/Ymir.Core/YmirServicePublicationDocument.cs
new file mode 100644
index 0000000..da1d154
--- /dev/null
+++ b/src/Ymir.Core/YmirServicePublicationDocument.cs
@@ -0,0 +1,187 @@
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using GameCult.Eve.Surface;
+using CultMath;
+using MessagePack;
+
+namespace Ymir.Core;
+
+[CultDocument("gamecult.ymir.provider_advertisement", "gamecult.ymir.provider_advertisement.v1")]
+[MessagePackObject]
+public sealed class YmirProviderAdvertisementDocument
+{
+ [Key(0)]
+ [CultName]
+ public string ProviderId { get; set; } = "ymir.physics";
+
+ [Key(1)]
+ public string Title { get; set; } = "Ymir Physics";
+
+ [Key(2)]
+ public string Description { get; set; } = "GameCult physics/world substrate engine built on CultMath.";
+
+ [Key(3)]
+ public string[] Owns { get; set; } = [];
+
+ [Key(4)]
+ public string[] DoesNotOwn { get; set; } = [];
+
+ [Key(5)]
+ public YmirTransportLoweringDocument[] CommandLowerings { get; set; } = [];
+
+ [Key(6)]
+ public string UpdatedAtUtc { get; set; } = "";
+}
+
+[CultDocument("gamecult.ymir.operator_state", "gamecult.ymir.operator_state.v1")]
+[MessagePackObject]
+public sealed class YmirOperatorStateDocument
+{
+ [Key(0)]
+ [CultName]
+ public string ProviderId { get; set; } = "ymir.physics";
+
+ [Key(1)]
+ public string Status { get; set; } = "mvp";
+
+ [Key(2)]
+ public string StateOwner { get; set; } = "Ymir.Core step and query results";
+
+ [Key(3)]
+ public string NumericSubstrate { get; set; } = "CultMath";
+
+ [Key(4)]
+ public string BatchKernel { get; set; } = "";
+
+ [Key(5)]
+ public string Persistence { get; set; } = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1";
+
+ [Key(6)]
+ public string UpdatedAtUtc { get; set; } = "";
+}
+
+[MessagePackObject]
+public sealed class YmirTransportLoweringDocument
+{
+ [Key(0)]
+ public string Label { get; set; } = "";
+
+ [Key(1)]
+ public string Method { get; set; } = "POST";
+
+ [Key(2)]
+ public string Path { get; set; } = "";
+
+ [Key(3)]
+ public string Purpose { get; set; } = "";
+}
+
+public static class YmirServicePublication
+{
+ public static YmirProviderAdvertisementDocument ProviderAdvertisement(DateTimeOffset now) => new()
+ {
+ ProviderId = "ymir.physics",
+ Title = "Ymir Physics",
+ Description = "GameCult physics/world substrate engine built on CultMath.",
+ Owns =
+ [
+ "physics simulation truth",
+ "body integration",
+ "field acceleration",
+ "contact generation",
+ "collision query semantics",
+ "SoA CultCache world-state persistence"
+ ],
+ DoesNotOwn =
+ [
+ "Unity scene truth",
+ "rendering",
+ "gameplay damage policy",
+ "editor authoring truth"
+ ],
+ CommandLowerings = [],
+ UpdatedAtUtc = FormatUtc(now)
+ };
+
+ public static YmirOperatorStateDocument OperatorState(DateTimeOffset now) => new()
+ {
+ ProviderId = "ymir.physics",
+ Status = "mvp",
+ StateOwner = "Ymir.Core step and query results",
+ NumericSubstrate = "CultMath",
+ BatchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}",
+ Persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1",
+ UpdatedAtUtc = FormatUtc(now)
+ };
+
+ public static EveSurfaceDocument EveSurface(YmirOperatorStateDocument diagnostics) =>
+ GameCult.Eve.Surface.EveSurface.Create("ymir.physics.operator")
+ .Provider(diagnostics.ProviderId, "physics.operator")
+ .Title("Ymir Physics")
+ .UpdatedAtUtc(diagnostics.UpdatedAtUtc)
+ .RootColumn("ymir.physics.operator.root", root => root
+ .Metric("ymir.physics.operator.status", "Status", diagnostics.Status)
+ .Metric("ymir.physics.operator.owner", "State owner", diagnostics.StateOwner)
+ .Metric("ymir.physics.operator.math", "Numeric substrate", diagnostics.NumericSubstrate)
+ .Metric("ymir.physics.operator.batch", "Batch kernel", diagnostics.BatchKernel)
+ .Metric("ymir.physics.operator.persistence", "Persistence", diagnostics.Persistence))
+ .Build();
+
+ public static EveSurfaceDocument EveSurface(DateTimeOffset now)
+ {
+ return EveSurface(OperatorState(now));
+ }
+
+ private static string FormatUtc(DateTimeOffset value) => value.UtcDateTime.ToString("O");
+}
+
+public static class YmirServicePublicationStore
+{
+ public const string SurfaceRecordKey = "surface:ymir.physics.operator";
+
+ public static async Task RegenerateDerivedStoreAsync(string path, DateTimeOffset now)
+ {
+ DeleteDerivedStore(path);
+ await PublishAsync(path, now).ConfigureAwait(false);
+ }
+
+ public static async Task PublishAsync(string path, DateTimeOffset now)
+ {
+ var cache = await CultCacheMessagePack.OpenAsync(
+ path,
+ new CultCacheOpenOptions
+ {
+ UseDirectoryStore = true,
+ FlushOnDispose = true,
+ StoreFlushOnDispose = true
+ }).ConfigureAwait(false);
+
+ using (cache)
+ {
+ var operatorState = YmirServicePublication.OperatorState(now);
+ await cache.UpsertAsync(
+ YmirServicePublication.ProviderAdvertisement(now),
+ new CultRecordHandle(new CultRecordKey("ymir:provider:ymir.physics")))
+ .ConfigureAwait(false);
+ await cache.UpsertAsync(
+ operatorState,
+ new CultRecordHandle(new CultRecordKey("ymir:operator:ymir.physics")))
+ .ConfigureAwait(false);
+ await cache.UpsertAsync(
+ YmirServicePublication.EveSurface(operatorState),
+ new CultRecordHandle(new CultRecordKey(SurfaceRecordKey)))
+ .ConfigureAwait(false);
+ await cache.FlushAsync().ConfigureAwait(false);
+ }
+ }
+
+ private static void DeleteDerivedStore(string path)
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path);
+ if (Directory.Exists(recordsPath))
+ Directory.Delete(recordsPath, recursive: true);
+ }
+}
diff --git a/src/ymir-daemon/Program.cs b/src/ymir-daemon/Program.cs
index b4beb73..88b4b49 100644
--- a/src/ymir-daemon/Program.cs
+++ b/src/ymir-daemon/Program.cs
@@ -1,5 +1,3 @@
-using System.Net;
-using System.Text;
using System.Text.Json;
using CultMath;
using Ymir.Core;
@@ -19,11 +17,8 @@ internal static class Program
switch (command)
{
- case "provider":
- WriteJson(ProviderAdvertisement());
- return 0;
- case "operator-state":
- WriteJson(OperatorState());
+ case "publish-service":
+ await PublishServiceAsync(ValueAfter(args, "--path") ?? ".ymir/service.cc");
return 0;
case "step-sample":
WriteJson(new YmirSimulator().Step(SampleRequest()));
@@ -33,9 +28,6 @@ internal static class Program
return 0;
case "step":
return RunStep(args);
- case "serve":
- await ServeAsync(ParsePort(args, 8877));
- return 0;
default:
PrintHelp();
return command is "help" or "-h" or "--help" ? 0 : 1;
@@ -65,146 +57,6 @@ internal static class Program
return 0;
}
- private static async Task ServeAsync(int port)
- {
- using var listener = new HttpListener();
- listener.Prefixes.Add($"http://127.0.0.1:{port}/");
- listener.Start();
- Console.WriteLine($"Ymir serving on http://127.0.0.1:{port}/");
-
- while (true)
- {
- var context = await listener.GetContextAsync();
- _ = Task.Run(() => HandleRequestAsync(context));
- }
- }
-
- private static async Task HandleRequestAsync(HttpListenerContext context)
- {
- try
- {
- var path = context.Request.Url?.AbsolutePath ?? "/";
- var method = context.Request.HttpMethod;
-
- if (method == "GET" && path == "/health")
- {
- await WriteResponseAsync(context, new { ok = true, service = "ymir", checkedAt = DateTimeOffset.UtcNow });
- return;
- }
-
- if (method == "GET" && path == "/provider-advertisement")
- {
- await WriteResponseAsync(context, ProviderAdvertisement());
- return;
- }
-
- if (method == "GET" && path == "/operator-state")
- {
- await WriteResponseAsync(context, OperatorState());
- return;
- }
-
- if (method == "GET" && path == "/eve/operator")
- {
- await WriteResponseAsync(context, EveOperatorSurface());
- return;
- }
-
- if (method == "POST" && path == "/simulate/step")
- {
- using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
- var body = await reader.ReadToEndAsync();
- var request = JsonSerializer.Deserialize(body, JsonOptions);
- if (request is null)
- {
- await WriteErrorAsync(context, 400, "Invalid simulation request.");
- return;
- }
-
- await WriteResponseAsync(context, new YmirSimulator().Step(request));
- return;
- }
-
- await WriteErrorAsync(context, 404, "No such Ymir route.");
- }
- catch (Exception error)
- {
- await WriteErrorAsync(context, 500, error.Message);
- }
- }
-
- private static object ProviderAdvertisement() => new
- {
- schema = "gamecult.provider_advertisement.v1",
- providerId = "ymir.physics",
- title = "Ymir Physics",
- description = "GameCult physics/world substrate engine built on CultMath.",
- authority = new
- {
- owns = new[]
- {
- "physics simulation truth",
- "body integration",
- "field acceleration",
- "contact generation",
- "collision query semantics",
- "SoA CultCache world-state persistence"
- },
- doesNotOwn = new[]
- {
- "Unity scene truth",
- "rendering",
- "gameplay damage policy",
- "editor authoring truth"
- }
- },
- routes = new
- {
- health = "/health",
- providerAdvertisement = "/provider-advertisement",
- operatorState = "/operator-state",
- operatorSurface = "/eve/operator",
- step = "/simulate/step"
- }
- };
-
- private static object OperatorState() => new
- {
- schema = "gamecult.ymir.operator_state.v0",
- providerId = "ymir.physics",
- status = "mvp",
- stateOwner = "Ymir.Core step result; JSON is compatibility witness only",
- numericSubstrate = "CultMath",
- batchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}",
- persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v0",
- updatedAt = DateTimeOffset.UtcNow
- };
-
- private static object EveOperatorSurface() => new
- {
- schema = "gamecult.eve.surface.v1",
- providerId = "ymir.physics",
- title = "Ymir Physics",
- updatedAt = DateTimeOffset.UtcNow,
- surface = new
- {
- root = new
- {
- type = "panel",
- title = "Ymir Physics",
- children = new object[]
- {
- new { type = "stat", label = "Status", value = "mvp" },
- new { type = "stat", label = "Owner", value = "physics simulation truth" },
- new { type = "stat", label = "Math", value = "CultMath" },
- new { type = "stat", label = "Batch lanes", value = BatchMath.LaneCount.ToString() },
- new { type = "stat", label = "Persistence", value = "CultCache SoA world_state.v0" },
- new { type = "route", label = "Step", value = "POST /simulate/step" }
- }
- }
- }
- };
-
private static SimulationStepRequest SampleRequest() => new(
0.1f,
new YmirWorld(
@@ -236,30 +88,24 @@ internal static class Program
});
}
- private static async Task WriteResponseAsync(HttpListenerContext context, object value)
+ private static async Task PublishServiceAsync(string path)
{
- var json = JsonSerializer.Serialize(value, JsonOptions);
- var bytes = Encoding.UTF8.GetBytes(json);
- context.Response.ContentType = "application/json; charset=utf-8";
- context.Response.ContentLength64 = bytes.Length;
- await context.Response.OutputStream.WriteAsync(bytes);
- context.Response.Close();
- }
-
- private static Task WriteErrorAsync(HttpListenerContext context, int statusCode, string message)
- {
- context.Response.StatusCode = statusCode;
- return WriteResponseAsync(context, new { ok = false, error = message });
+ await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
+ WriteJson(new
+ {
+ ok = true,
+ path,
+ records = new[]
+ {
+ "ymir:provider:ymir.physics",
+ "ymir:operator:ymir.physics",
+ "surface:ymir.physics.operator"
+ }
+ });
}
private static void WriteJson(object value) => Console.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
- private static int ParsePort(string[] args, int defaultPort)
- {
- var raw = ValueAfter(args, "--port");
- return int.TryParse(raw, out var port) ? port : defaultPort;
- }
-
private static string? ValueAfter(string[] args, string key)
{
var index = Array.IndexOf(args, key);
@@ -272,12 +118,10 @@ internal static class Program
Ymir physics daemon
Commands:
- provider
- operator-state
+ publish-service [--path .ymir/service.cc] Regenerate the derived service publication.
step-sample
save-sample [--path .ymir/worlds.cc]
step --request
- serve [--port 8877]
""");
}
}
diff --git a/tests/Ymir.Tests/YmirServicePublicationTests.cs b/tests/Ymir.Tests/YmirServicePublicationTests.cs
new file mode 100644
index 0000000..5aedbac
--- /dev/null
+++ b/tests/Ymir.Tests/YmirServicePublicationTests.cs
@@ -0,0 +1,119 @@
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using GameCult.Eve.Surface;
+using Ymir.Core;
+
+namespace Ymir.Tests;
+
+public sealed class YmirServicePublicationTests
+{
+ [Fact]
+ public void EveSurfaceUsesCanonicalDocumentAndOperatorMetrics()
+ {
+ var diagnostics = new YmirOperatorStateDocument
+ {
+ ProviderId = "ymir.physics",
+ Status = "ready",
+ StateOwner = "diagnostic-owner",
+ NumericSubstrate = "diagnostic-math",
+ BatchKernel = "diagnostic-batch",
+ Persistence = "diagnostic-persistence",
+ UpdatedAtUtc = "2026-07-13T12:00:00.0000000Z"
+ };
+
+ EveSurfaceDocument surface = YmirServicePublication.EveSurface(diagnostics);
+
+ Assert.Equal(EveSurfaceDocument.SchemaId, surface.Schema);
+ Assert.Equal("ymir.physics.operator", surface.Surface.Id);
+ Assert.Equal("partition", surface.Surface.Root.Kind);
+ Assert.Equal(diagnostics.UpdatedAtUtc, surface.UpdatedAtUtc);
+ Assert.Equal(
+ new[]
+ {
+ ("Status", diagnostics.Status),
+ ("State owner", diagnostics.StateOwner),
+ ("Numeric substrate", diagnostics.NumericSubstrate),
+ ("Batch kernel", diagnostics.BatchKernel),
+ ("Persistence", diagnostics.Persistence)
+ },
+ surface.Surface.Root.Children
+ .Select(child => (child.GetProp("label"), child.GetProp("value")))
+ .ToArray());
+ }
+
+ [Fact]
+ public void CombinedRegistryContainsExactlyOneCanonicalSurfaceDescriptor()
+ {
+ var registry = new CultDocumentRegistry();
+
+ var descriptors = registry.AllDescriptors
+ .Where(descriptor => descriptor.SchemaVersion == EveSurfaceDocument.SchemaId)
+ .ToArray();
+
+ var descriptor = Assert.Single(descriptors);
+ Assert.Equal(typeof(EveSurfaceDocument), descriptor.DocumentType);
+ }
+
+ [Fact]
+ public async Task PublicationPersistsAndReopensWithCombinedRegistry()
+ {
+ var path = TemporaryStorePath();
+ try
+ {
+ await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
+
+ using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions
+ {
+ Registry = new CultDocumentRegistry(),
+ UseDirectoryStore = true
+ });
+
+ var surface = cache.Get(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey));
+ Assert.NotNull(surface);
+ Assert.Equal("ymir.physics.operator", surface.Surface.Id);
+ Assert.Single(cache.GetAll());
+ }
+ finally
+ {
+ DeleteStore(path);
+ }
+ }
+
+ [Fact]
+ public async Task RegenerationReplacesUnreadableLegacyDerivedStoreBeforeOpen()
+ {
+ var path = TemporaryStorePath();
+ var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path);
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ Directory.CreateDirectory(recordsPath);
+ await File.WriteAllBytesAsync(path, [0xc1]);
+ await File.WriteAllBytesAsync(Path.Combine(recordsPath, "legacy.msgpack"), [0xc1]);
+
+ await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
+
+ using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions
+ {
+ Registry = new CultDocumentRegistry(),
+ UseDirectoryStore = true
+ });
+ Assert.NotNull(cache.Get(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey)));
+ Assert.False(File.Exists(Path.Combine(recordsPath, "legacy.msgpack")));
+ }
+ finally
+ {
+ DeleteStore(path);
+ }
+ }
+
+ private static string TemporaryStorePath() =>
+ Path.Combine(Path.GetTempPath(), "ymir-tests", Guid.NewGuid().ToString("N"), "service.cc");
+
+ private static void DeleteStore(string path)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (directory is not null && Directory.Exists(directory))
+ Directory.Delete(directory, recursive: true);
+ }
+}