Make Unity own daemon readiness

This commit is contained in:
Meta Vens 2026-07-21 20:10:13 +01:00
commit b312bfec3d
5 changed files with 155 additions and 63 deletions

View file

@ -56,27 +56,6 @@ var latestFrame = await node.MutableDocument<AetheriaRuntimeDaemonFrameDocument>
TraceStartup("latest-frame");
var unityBundles = BuildUnityBundleArtifactSet(options);
TraceStartup("provider-asset-bundles");
await using var cultMeshClientHost = await StartClientCultMeshHostAsync(
node, options, unityBundles, () => latestFrame).ConfigureAwait(false);
TraceStartup("client-host");
Console.WriteLine($"Aetheria client CultMesh endpoint: {cultMeshClientHost.ControlEndpoint}");
Console.WriteLine($"Aetheria client CDN endpoint: {cultMeshClientHost.ContentEndpoint}");
Console.WriteLine($"Aetheria client realtime endpoint: {cultMeshClientHost.RealtimeEndpoint}");
Console.WriteLine($"Aetheria daemon transport-ready in {startup.Elapsed.TotalMilliseconds:0.###}ms.");
using var clientSubscriptions = new CultNetDatabaseSubscriptionServer(cultMeshClientHost.Control, node.Database);
var managedViewportDemand = new AetheriaManagedViewportDemandState();
clientSubscriptions.DemandChanged += managedViewportDemand.Observe;
if (traceClientTransport)
clientSubscriptions.DemandChanged += demand => Console.WriteLine(
$"CultMesh state demand consumer={demand.ConsumerRuntimeId} subscription={demand.SubscriptionId} " +
$"active={demand.Active} sameMachine={demand.SameMachine} " +
$"records=[{string.Join(",", demand.RecordKeys)}] schemas=[{string.Join(",", demand.SchemaIds)}] " +
$"bodies=[{string.Join(",", demand.BodyIds)}] transports=[{string.Join(",", demand.SupportedBodyTransports)}]");
using var bodyDemand = new CultMeshBodyDemandTracker(clientSubscriptions);
using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher(
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
bodyDemand);
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
TraceStartup("world-document");
var persistedRuntimeCatalog = await node.Database
@ -142,7 +121,6 @@ else
physicsPersistence);
initialPublication = initialPrepared.Publication;
}
ThrowIfClientHostFaulted(cultMeshClientHost);
if (!publishRestoredFrame)
{
await PublishClientGameplayDocumentsAsync(
@ -155,6 +133,27 @@ if (!publishRestoredFrame)
publishTopology: true).ConfigureAwait(false);
}
TraceStartup("client-bootstrap");
await using var cultMeshClientHost = await StartClientCultMeshHostAsync(
node, options, unityBundles, () => latestFrame).ConfigureAwait(false);
TraceStartup("client-host");
Console.WriteLine($"Aetheria client CultMesh endpoint: {cultMeshClientHost.ControlEndpoint}");
Console.WriteLine($"Aetheria client CDN endpoint: {cultMeshClientHost.ContentEndpoint}");
Console.WriteLine($"Aetheria client realtime endpoint: {cultMeshClientHost.RealtimeEndpoint}");
Console.WriteLine($"Aetheria daemon transport-ready in {startup.Elapsed.TotalMilliseconds:0.###}ms.");
using var clientSubscriptions = new CultNetDatabaseSubscriptionServer(cultMeshClientHost.Control, node.Database);
var managedViewportDemand = new AetheriaManagedViewportDemandState();
clientSubscriptions.DemandChanged += managedViewportDemand.Observe;
if (traceClientTransport)
clientSubscriptions.DemandChanged += demand => Console.WriteLine(
$"CultMesh state demand consumer={demand.ConsumerRuntimeId} subscription={demand.SubscriptionId} " +
$"active={demand.Active} sameMachine={demand.SameMachine} " +
$"records=[{string.Join(",", demand.RecordKeys)}] schemas=[{string.Join(",", demand.SchemaIds)}] " +
$"bodies=[{string.Join(",", demand.BodyIds)}] transports=[{string.Join(",", demand.SupportedBodyTransports)}]");
using var bodyDemand = new CultMeshBodyDemandTracker(clientSubscriptions);
using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher(
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
bodyDemand);
ThrowIfClientHostFaulted(cultMeshClientHost);
await PublishHotEntityStateAsync(
node, soaPublisher, hotState, latestFrame!, ingressState.Catalog, cultMeshClientHost).ConfigureAwait(false);
TraceStartup("hot-entity-state");
@ -505,10 +504,35 @@ static bool HasPersistedClientBootstrap(
node.Cache.Get<EveAssetCatalogDocument>(AetheriaRuntimeVerseRecordKeys.EveAssetCatalog) != null &&
input?.Version == frame.FrameId &&
game?.Version == frame.FrameId &&
HasCurrentEntityBodyContract(game.Surface.Root, requirePointer: true) &&
reactive?.Version == frame.FrameId &&
HasCurrentEntityBodyContract(reactive.Surface.Root, requirePointer: false) &&
map?.Version == frame.FrameId;
}
static bool HasCurrentEntityBodyContract(EveSurfaceComponent component, bool requirePointer)
{
var hasPointer = !string.IsNullOrWhiteSpace(component.GetProp("entityViewPointerId"));
if (hasPointer && !string.Equals(
component.GetProp("entityBodyId"),
AetheriaRuntimeDaemonSoaFramePublisher.BodyId,
StringComparison.Ordinal))
return false;
var children = component.Children ?? Array.Empty<EveSurfaceComponent>();
var childHasPointer = false;
foreach (var child in children)
{
if (!HasCurrentEntityBodyContract(child, requirePointer: false))
return false;
childHasPointer |= ContainsEntityViewPointer(child);
}
return !requirePointer || hasPointer || childHasPointer;
}
static bool ContainsEntityViewPointer(EveSurfaceComponent component) =>
!string.IsNullOrWhiteSpace(component.GetProp("entityViewPointerId")) ||
(component.Children ?? Array.Empty<EveSurfaceComponent>()).Any(ContainsEntityViewPointer);
static async Task PersistPreparedDocumentsAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,

View file

@ -1262,12 +1262,21 @@ static void RequireClientTransportPlaneOwnership(string root)
{
var daemonPath = Path.Combine(root, "Aetheria.State.Daemon", "Program.cs");
var launcherPath = Path.Combine(root, "scripts", "run-aetheria-unity.ps1");
var editorLauncherPath = Path.Combine(root, "scripts", "start-aetheria-daemon-editor.ps1");
var editorControllerPath = Path.Combine(
root, "Aetheria.Unity", "Assets", "Editor", "AetheriaDaemonDevelopmentWindow.cs");
var daemon = File.Exists(daemonPath)
? File.ReadAllText(daemonPath)
: throw new InvalidOperationException("Cannot verify client transport ownership; daemon Program.cs is missing.");
var launcher = File.Exists(launcherPath)
? File.ReadAllText(launcherPath)
: throw new InvalidOperationException("Cannot verify client transport ownership; Unity launcher is missing.");
var editorLauncher = File.Exists(editorLauncherPath)
? File.ReadAllText(editorLauncherPath)
: throw new InvalidOperationException("Cannot verify client transport ownership; Unity editor daemon launcher is missing.");
var editorController = File.Exists(editorControllerPath)
? File.ReadAllText(editorControllerPath)
: throw new InvalidOperationException("Cannot verify client transport ownership; Unity editor daemon controller is missing.");
var required = new[]
{
"new TcpFramedCultNetSchemaServer(",
@ -1324,7 +1333,36 @@ static void RequireClientTransportPlaneOwnership(string root)
"Aetheria reintroduced RUDP or schema-carried bulk state into the client boundary: " +
string.Join(", ", present));
Console.WriteLine("Client transport ownership: TCP control/CDN and QUIC realtime are advertised separately; RUDP and schema-carried bodies are absent");
var restoreIndex = daemon.IndexOf(
"using var physicsPersistence = await AetheriaYmirPersistenceCoordinator.OpenAsync(",
StringComparison.Ordinal);
var clientHostIndex = daemon.IndexOf(
"await using var cultMeshClientHost = await StartClientCultMeshHostAsync(",
StringComparison.Ordinal);
if (restoreIndex < 0 || clientHostIndex < 0 || clientHostIndex < restoreIndex)
throw new InvalidOperationException(
"Aetheria client transport must not listen before daemon-private Ymir restoration succeeds.");
var requiredEditorResetSymbols = new[]
{
"$ymirStatePath = \"$statePath.ymir.cc\"",
"$ymirRecordsPath = \"$ymirStatePath.records\"",
"$ymirStatePath, $ymirRecordsPath"
};
var missingEditorResetSymbols = requiredEditorResetSymbols
.Where(symbol => !editorLauncher.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingEditorResetSymbols.Length > 0)
throw new InvalidOperationException(
"Aetheria editor reimport does not reset every daemon-private Ymir state path: " +
string.Join(", ", missingEditorResetSymbols));
if (editorLauncher.Contains("Start-Process", StringComparison.Ordinal) ||
!editorController.Contains("Process.Start(new ProcessStartInfo(DaemonExePath, arguments)", StringComparison.Ordinal) ||
!editorController.Contains("SessionState.SetInt(ProcessIdSessionKey, daemon.Id)", StringComparison.Ordinal))
throw new InvalidOperationException(
"Unity must directly own the Debug daemon process after synchronous build/import preparation.");
Console.WriteLine("Client transport ownership: Ymir restores before transport listen; Unity directly owns the prepared Debug daemon and matching private state");
}
static void RequireDaemonPlayableRunGenerationAuthority(string root)

View file

@ -225,6 +225,8 @@ namespace Aetheria.Editor
private static string ErrorLogPath => Path.Combine(ArtifactsRoot, "daemon.error.log");
private static string LauncherLogPath => Path.Combine(ArtifactsRoot, "launcher.log");
private static string LauncherErrorPath => Path.Combine(ArtifactsRoot, "launcher.error.log");
private static string DaemonExePath => Path.Combine(
ProjectRoot, "Aetheria.State.Daemon", "bin", "Debug", "net10.0", "Aetheria.State.Daemon.exe");
public static void Start(bool enterPlayWhenReady, bool forceImport = false)
{
@ -249,8 +251,7 @@ namespace Aetheria.Editor
var script = Path.Combine(ProjectRoot, "scripts", "start-aetheria-daemon-editor.ps1");
var arguments = $"-NoProfile -ExecutionPolicy Bypass -File {Quote(script)} " +
$"-Root {Quote(ProjectRoot)} -State {Quote(StatePath)} -Port {Port} " +
$"-PidFile {Quote(PidPath)} -LogFile {Quote(LogPath)} -ErrorLogFile {Quote(ErrorLogPath)} " +
$"-Root {Quote(ProjectRoot)} -State {Quote(StatePath)} " +
$"-CultLibRoot {Quote(CultLibRoot)} -YmirRoot {Quote(YmirRoot)} -EveUnityRoot {Quote(EveUnityRoot)}" +
(forceImport ? " -ForceImport" : "");
_launcher = Process.Start(new ProcessStartInfo("powershell.exe", arguments)
@ -352,6 +353,21 @@ namespace Aetheria.Editor
Changed?.Invoke();
return;
}
if (exitCode == 0 && !TryRefreshDaemon())
{
try
{
LaunchDaemon();
}
catch (Exception exception)
{
_lastError = exception.ToString();
_status = "Daemon launch failed";
_enterPlayWhenReady = false;
Changed?.Invoke();
return;
}
}
}
if (TryRefreshDaemon() && IsEndpointReady())
@ -570,6 +586,44 @@ namespace Aetheria.Editor
Path.Combine(ProjectRoot, "Aetheria.Unity", "Build", "AssetCache"));
}
private static void LaunchDaemon()
{
if (!File.Exists(DaemonExePath))
throw new FileNotFoundException("Prepared Aetheria Debug daemon is missing.", DaemonExePath);
File.WriteAllText(LogPath, "");
File.WriteAllText(ErrorLogPath, "");
var arguments =
$"--root {Quote(ProjectRoot)} " +
$"--state {Quote(StatePath)} " +
"--client-cultmesh-host 127.0.0.1 " +
"--client-cultmesh-advertise-host 127.0.0.1 " +
$"--client-cultmesh-port {Port} " +
"--tick-interval-ms 20 --fixed-delta-ms 20 --no-odin-announcements";
var daemon = Process.Start(new ProcessStartInfo(DaemonExePath, arguments)
{
WorkingDirectory = ProjectRoot,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
}) ?? throw new InvalidOperationException("Aetheria Debug daemon process did not start.");
daemon.OutputDataReceived += (_, value) => AppendLine(LogPath, value.Data);
daemon.ErrorDataReceived += (_, value) => AppendLine(ErrorLogPath, value.Data);
daemon.BeginOutputReadLine();
daemon.BeginErrorReadLine();
_daemon?.Dispose();
_daemon = daemon;
SessionState.SetInt(ProcessIdSessionKey, daemon.Id);
var temporaryPidPath = PidPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(temporaryPidPath, daemon.Id.ToString(CultureInfo.InvariantCulture));
if (File.Exists(PidPath)) File.Delete(PidPath);
File.Move(temporaryPidPath, PidPath);
_status = $"Starting Debug daemon (PID {daemon.Id})";
Changed?.Invoke();
}
private static void AppendLine(string path, string value)
{
if (value == null) return;

View file

@ -4,11 +4,12 @@ Open `Aetheria.Unity` in Unity and choose **Aetheria > Daemon Development**.
This is the interactive development path. The released-package witness remains
the automated integration proof and is not required for ordinary iteration.
The window builds and launches `Aetheria.State.Daemon` from
`bin/Debug/net10.0`, imports an isolated development state under
`Aetheria.Unity/Build` when necessary, and configures the generic EveUnity
client to connect directly to the daemon's local CultMesh endpoint. Odin is not
part of this path.
The window synchronously builds `Aetheria.State.Daemon` in
`bin/Debug/net10.0` and imports an isolated development state under
`Aetheria.Unity/Build` when necessary. After preparation succeeds, the Unity
editor directly launches and owns the daemon process, its PID, and its output
streams. The generic EveUnity client connects directly to the daemon's local
CultMesh endpoint. Odin is not part of this path.
The editor connects through the daemon's `cultnet+tcp` control endpoint. The
advertised session then selects the dedicated content and QUIC realtime planes;
@ -58,6 +59,7 @@ resumes; it does not simulate through the breakpoint.
- Daemon stderr: `Aetheria.Unity/Build/DaemonDevelopment/daemon.error.log`
- Preparation logs: `Aetheria.Unity/Build/DaemonDevelopment/launcher*.log`
`Reimport state & start` deletes only the isolated development `.cc` file and
its `.cultmesh` (or legacy `.records`) sidecar before importing it again. It
does not touch `GameData`, the source catalog, or another witness/run state.
`Reimport state & start` deletes only the isolated development `.cc` file, its
`.cultmesh` (or legacy `.records`) sidecar, and the matching daemon-private
`.ymir.cc` journal store before importing it again. It does not touch
`GameData`, the source catalog, or another witness/run state.

View file

@ -1,10 +1,6 @@
param(
[Parameter(Mandatory = $true)] [string] $Root,
[Parameter(Mandatory = $true)] [string] $State,
[Parameter(Mandatory = $true)] [int] $Port,
[Parameter(Mandatory = $true)] [string] $PidFile,
[Parameter(Mandatory = $true)] [string] $LogFile,
[Parameter(Mandatory = $true)] [string] $ErrorLogFile,
[Parameter(Mandatory = $true)] [string] $CultLibRoot,
[Parameter(Mandatory = $true)] [string] $YmirRoot,
[Parameter(Mandatory = $true)] [string] $EveUnityRoot,
@ -19,6 +15,8 @@ $importProject = Join-Path $rootPath "Aetheria.State.Import\Aetheria.State.Impor
$daemonExe = Join-Path $rootPath "Aetheria.State.Daemon\bin\Debug\net10.0\Aetheria.State.Daemon.exe"
$recordsPath = "$statePath.records"
$cultMeshPath = [IO.Path]::ChangeExtension($statePath, ".cultmesh")
$ymirStatePath = "$statePath.ymir.cc"
$ymirRecordsPath = "$ymirStatePath.records"
$cultLibPath = [IO.Path]::GetFullPath($CultLibRoot)
$ymirPath = [IO.Path]::GetFullPath($YmirRoot)
$eveUnityPath = [IO.Path]::GetFullPath($EveUnityRoot)
@ -30,8 +28,6 @@ foreach ($required in @($daemonProject, $importProject, $cultLibPath, $ymirPath,
}
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $statePath) | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PidFile) | Out-Null
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
& dotnet build $daemonProject -c Debug --nologo `
"-p:CultLibRoot=$cultLibPath" "-p:YmirRoot=$ymirPath" "-p:EveUnityRoot=$eveUnityPath"
@ -40,7 +36,7 @@ if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $daemonExe)) {
}
if ($ForceImport) {
foreach ($ownedStatePath in @($statePath, $recordsPath, $cultMeshPath)) {
foreach ($ownedStatePath in @($statePath, $recordsPath, $cultMeshPath, $ymirStatePath, $ymirRecordsPath)) {
if (Test-Path -LiteralPath $ownedStatePath) {
Remove-Item -LiteralPath $ownedStatePath -Recurse -Force
}
@ -59,26 +55,4 @@ if (-not $hasImportedState) {
}
}
$daemonArguments = @(
"--root", $rootPath,
"--state", $statePath,
"--client-cultmesh-host", "127.0.0.1",
"--client-cultmesh-advertise-host", "127.0.0.1",
"--client-cultmesh-port", $Port,
"--tick-interval-ms", 20,
"--fixed-delta-ms", 20,
"--no-odin-announcements"
)
$daemon = Start-Process -FilePath $daemonExe `
-ArgumentList $daemonArguments `
-WorkingDirectory $rootPath `
-WindowStyle Hidden `
-RedirectStandardOutput $LogFile `
-RedirectStandardError $ErrorLogFile `
-PassThru
$temporaryPidFile = "$PidFile.$([Guid]::NewGuid().ToString('N')).tmp"
Set-Content -LiteralPath $temporaryPidFile -Value $daemon.Id -Encoding Ascii
Move-Item -LiteralPath $temporaryPidFile -Destination $PidFile -Force
Write-Host "Aetheria Debug daemon PID: $($daemon.Id)"
Write-Host "Aetheria Debug daemon log: $LogFile"
Write-Host "Aetheria Debug daemon preparation complete: $daemonExe"