diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 15ca92486..0072bdd90 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -56,27 +56,6 @@ var latestFrame = await node.MutableDocument 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(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(); + 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()).Any(ContainsEntityViewPointer); + static async Task PersistPreparedDocumentsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, diff --git a/Aetheria.State.Verify/Program.cs b/Aetheria.State.Verify/Program.cs index 8bf576372..5131c4c27 100644 --- a/Aetheria.State.Verify/Program.cs +++ b/Aetheria.State.Verify/Program.cs @@ -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) diff --git a/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs b/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs index 717ee9001..6738ea7dd 100644 --- a/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs +++ b/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs @@ -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; diff --git a/docs/unity-daemon-development.md b/docs/unity-daemon-development.md index fe48d6fab..21cec904b 100644 --- a/docs/unity-daemon-development.md +++ b/docs/unity-daemon-development.md @@ -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. diff --git a/scripts/start-aetheria-daemon-editor.ps1 b/scripts/start-aetheria-daemon-editor.ps1 index 8b3fc8cdf..a7345328f 100644 --- a/scripts/start-aetheria-daemon-editor.ps1 +++ b/scripts/start-aetheria-daemon-editor.ps1 @@ -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"