Aetheria/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs
2026-07-22 00:12:13 +01:00

644 lines
26 KiB
C#

using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using GameCult.Eve.UnityScene;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using Debug = UnityEngine.Debug;
namespace Aetheria.Editor
{
public sealed class AetheriaDaemonDevelopmentWindow : EditorWindow
{
private const string MenuPath = "Aetheria/Daemon Development";
private const string PortPreference = "Aetheria.DaemonDevelopment.Port";
internal const string ClientScenePath = "Assets/Aetheria.unity";
private Vector2 _scroll;
[MenuItem(MenuPath)]
public static void Open() => GetWindow<AetheriaDaemonDevelopmentWindow>("Aetheria Daemon");
public static void OpenClientScene()
{
Open();
EnsureClientSceneLoaded();
}
internal static bool IsClientSceneLoaded()
{
for (var index = 0; index < SceneManager.sceneCount; index++)
{
if (string.Equals(SceneManager.GetSceneAt(index).path, ClientScenePath, StringComparison.Ordinal))
return true;
}
return false;
}
internal static void EnsureClientSceneLoaded()
{
for (var index = 0; index < SceneManager.sceneCount; index++)
{
var scene = SceneManager.GetSceneAt(index);
if (!string.Equals(scene.path, ClientScenePath, StringComparison.Ordinal)) continue;
SceneManager.SetActiveScene(scene);
return;
}
var clientScene = EditorSceneManager.OpenScene(ClientScenePath, OpenSceneMode.Additive);
SceneManager.SetActiveScene(clientScene);
}
private void OnEnable()
{
minSize = new Vector2(470, 390);
AetheriaDaemonDevelopmentController.Changed += Repaint;
}
private void OnDisable() => AetheriaDaemonDevelopmentController.Changed -= Repaint;
private void OnGUI()
{
_scroll = EditorGUILayout.BeginScrollView(_scroll);
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Authoritative daemon", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"This window owns only the local development process. Gameplay, pause, stepping, and world truth remain daemon-owned Eve operations.",
MessageType.Info);
var port = EditorPrefs.GetInt(PortPreference, 3076);
var nextPort = EditorGUILayout.IntField("CultMesh port", port);
if (nextPort != port && nextPort is > 0 and <= 65535)
EditorPrefs.SetInt(PortPreference, nextPort);
EditorGUILayout.Space(6);
EditorGUILayout.LabelField("Status", AetheriaDaemonDevelopmentController.Status);
EditorGUILayout.LabelField("Endpoint", AetheriaDaemonDevelopmentController.Endpoint);
EditorGUILayout.LabelField("PID", AetheriaDaemonDevelopmentController.ProcessIdText);
EditorGUILayout.LabelField("State", AetheriaDaemonDevelopmentController.StatePath, EditorStyles.wordWrappedLabel);
EditorGUILayout.LabelField("CultLib source", AetheriaDaemonDevelopmentController.CultLibRoot, EditorStyles.wordWrappedLabel);
EditorGUILayout.LabelField("Ymir source", AetheriaDaemonDevelopmentController.YmirRoot, EditorStyles.wordWrappedLabel);
EditorGUILayout.Space(8);
if (GUILayout.Button("Open Aetheria client scene"))
OpenClientScene();
EditorGUILayout.Space(4);
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = !AetheriaDaemonDevelopmentController.IsStarting &&
!AetheriaDaemonDevelopmentController.IsRunning &&
AetheriaDaemonDevelopmentController.IsPrepared;
if (GUILayout.Button("Start daemon"))
AetheriaDaemonDevelopmentController.Start();
GUI.enabled = AetheriaDaemonDevelopmentController.IsRunning ||
AetheriaDaemonDevelopmentController.IsStarting;
if (GUILayout.Button("Stop"))
AetheriaDaemonDevelopmentController.Stop();
GUI.enabled = true;
}
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = AetheriaDaemonDevelopmentController.IsRunning &&
!AetheriaDaemonDevelopmentController.IsStarting;
if (GUILayout.Button("Restart"))
AetheriaDaemonDevelopmentController.Restart();
GUI.enabled = !AetheriaDaemonDevelopmentController.IsRunning &&
!AetheriaDaemonDevelopmentController.IsStarting;
if (GUILayout.Button("Build daemon"))
AetheriaDaemonDevelopmentController.Build();
if (GUILayout.Button("Reimport state & build"))
AetheriaDaemonDevelopmentController.Build(forceImport: true);
GUI.enabled = true;
}
EditorGUILayout.Space(12);
EditorGUILayout.LabelField("Authoritative simulation", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = EditorApplication.isPlaying && AetheriaDaemonDevelopmentController.IsRunning;
if (GUILayout.Button("Pause daemon"))
AetheriaDaemonDevelopmentController.SubmitClockAction("simulation.pause");
if (GUILayout.Button("Advance one step"))
AetheriaDaemonDevelopmentController.SubmitClockAction("simulation.step");
if (GUILayout.Button("Resume real time"))
AetheriaDaemonDevelopmentController.SubmitClockAction("simulation.rate.realtime");
GUI.enabled = true;
}
EditorGUILayout.HelpBox(
"Unity's Pause button is mirrored to simulation.pause. Unpause submits simulation.rate.realtime. Advance One Step remains available here while the Unity player is paused.",
MessageType.None);
EditorGUILayout.Space(12);
EditorGUILayout.LabelField("Rider", EditorStyles.boldLabel);
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = AetheriaDaemonDevelopmentController.IsRunning;
if (GUILayout.Button("Copy daemon PID"))
AetheriaDaemonDevelopmentController.CopyProcessId();
GUI.enabled = true;
if (GUILayout.Button("Open daemon source"))
AetheriaDaemonDevelopmentController.OpenDaemonSource();
}
EditorGUILayout.HelpBox(
"In Rider use Run | Attach to Process and select the copied Aetheria.State.Daemon PID. The daemon is built and launched from bin/Debug/net10.0, so source breakpoints bind to this checkout.",
MessageType.None);
EditorGUILayout.Space(12);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Open daemon log"))
AetheriaDaemonDevelopmentController.OpenLog();
if (GUILayout.Button("Open error log"))
AetheriaDaemonDevelopmentController.OpenErrorLog();
}
if (!string.IsNullOrWhiteSpace(AetheriaDaemonDevelopmentController.LastError))
EditorGUILayout.HelpBox(AetheriaDaemonDevelopmentController.LastError, MessageType.Error);
EditorGUILayout.EndScrollView();
}
}
[InitializeOnLoad]
internal static class AetheriaDaemonDevelopmentController
{
private const string ProcessIdSessionKey = "Aetheria.DaemonDevelopment.ProcessId";
private const string PortPreference = "Aetheria.DaemonDevelopment.Port";
private static Process _launcher;
private static Process _daemon;
private static bool _restartWhenStopped;
private static string _pendingClockAction = "";
private static string _status = "Stopped";
private static string _lastError = "";
public static event Action Changed;
static AetheriaDaemonDevelopmentController()
{
EditorApplication.update += Update;
EditorApplication.quitting += Stop;
EditorApplication.pauseStateChanged += OnPauseStateChanged;
Reattach();
ApplyClientEnvironment();
}
public static bool IsRunning => TryRefreshDaemon();
public static bool IsStarting => _launcher != null && !_launcher.HasExited;
public static bool IsPrepared => File.Exists(DaemonExePath) && Directory.Exists(DotNetRootPath);
public static string Status => _status;
public static string LastError => _lastError;
public static string Endpoint => $"cultnet+tcp://127.0.0.1:{Port}";
public static string ProcessIdText => IsRunning ? _daemon.Id.ToString(CultureInfo.InvariantCulture) : "-";
public static string StatePath => Path.Combine(ProjectRoot, "Aetheria.Unity", "Build", "aetheria-unity-dev.cc");
public static string CultLibRoot => ResolveSibling("CultLib-codex-cultmesh-reliability", "CultLib-release", "CultLib");
public static string YmirRoot => ResolveSibling("Ymir-aetheria-integration", "Ymir");
public static string EveUnityRoot => ResolveSibling("EveUnity");
private static int Port => EditorPrefs.GetInt(PortPreference, 3076);
private static string ProjectRoot => Path.GetFullPath(Path.Combine(Application.dataPath, "..", ".."));
private static string ArtifactsRoot => Path.Combine(ProjectRoot, "Aetheria.Unity", "Build", "DaemonDevelopment");
private static string PidPath => Path.Combine(ArtifactsRoot, "daemon.pid");
private static string LogPath => Path.Combine(ArtifactsRoot, "daemon.log");
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");
private static string DotNetRootPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet");
public static void Start()
{
if (TryRefreshDaemon())
{
ApplyClientEnvironment();
return;
}
if (IsStarting)
return;
try
{
LaunchDaemon();
}
catch (Exception exception)
{
_lastError = exception.ToString();
_status = "Daemon launch failed";
Changed?.Invoke();
}
}
public static void Build(bool forceImport = false)
{
if (TryRefreshDaemon() || IsStarting)
return;
Directory.CreateDirectory(ArtifactsRoot);
File.Delete(PidPath);
File.WriteAllText(LauncherLogPath, "");
File.WriteAllText(LauncherErrorPath, "");
_lastError = "";
ApplyClientEnvironment();
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)} " +
$"-CultLibRoot {Quote(CultLibRoot)} -YmirRoot {Quote(YmirRoot)} -EveUnityRoot {Quote(EveUnityRoot)}" +
(forceImport ? " -ForceImport" : "");
_launcher = Process.Start(new ProcessStartInfo("powershell.exe", arguments)
{
WorkingDirectory = ProjectRoot,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
});
_launcher.OutputDataReceived += (_, value) => AppendLine(LauncherLogPath, value.Data);
_launcher.ErrorDataReceived += (_, value) => AppendLine(LauncherErrorPath, value.Data);
_launcher.BeginOutputReadLine();
_launcher.BeginErrorReadLine();
_status = forceImport ? "Reimporting state and building Debug daemon..." : "Building Debug daemon...";
Changed?.Invoke();
}
public static void Stop()
{
_pendingClockAction = "";
try
{
if (_launcher != null && !_launcher.HasExited)
_launcher.Kill();
}
catch (Exception exception)
{
Debug.LogWarning($"Could not stop Aetheria daemon launcher: {exception.Message}");
}
try
{
if (TryRefreshDaemon())
_daemon.Kill();
}
catch (Exception exception)
{
Debug.LogWarning($"Could not stop Aetheria daemon: {exception.Message}");
}
_launcher?.Dispose();
_launcher = null;
_daemon?.Dispose();
_daemon = null;
SessionState.EraseInt(ProcessIdSessionKey);
TryDelete(PidPath);
_status = "Stopped";
Changed?.Invoke();
}
public static void Restart()
{
_restartWhenStopped = true;
Stop();
}
public static void SubmitClockAction(string actionId)
{
_pendingClockAction = actionId ?? "";
TrySubmitPendingClockAction();
}
public static void CopyProcessId()
{
if (!TryRefreshDaemon()) return;
EditorGUIUtility.systemCopyBuffer = _daemon.Id.ToString(CultureInfo.InvariantCulture);
_status = $"Copied daemon PID {_daemon.Id} for Rider Attach to Process";
Changed?.Invoke();
}
public static void OpenDaemonSource()
{
var source = Path.Combine(ProjectRoot, "Aetheria.State.Daemon", "Program.cs");
EditorUtility.OpenWithDefaultApp(source);
}
public static void OpenLog() => OpenFile(LogPath);
public static void OpenErrorLog() => OpenFile(ErrorLogPath);
private static void Update()
{
if (_restartWhenStopped)
{
_restartWhenStopped = false;
Start();
return;
}
if (_launcher != null && _launcher.HasExited)
{
var exitCode = _launcher.ExitCode;
_launcher.Dispose();
_launcher = null;
if (exitCode != 0 && !TryRefreshDaemon())
{
_lastError = ReadTail(LauncherErrorPath, 24);
_status = $"Daemon preparation failed (exit {exitCode})";
Changed?.Invoke();
return;
}
if (exitCode == 0 && !TryRefreshDaemon())
{
_status = "Debug daemon prepared";
Changed?.Invoke();
}
}
if (TryRefreshDaemon() && IsEndpointReady())
{
if (!_status.StartsWith("Running", StringComparison.Ordinal))
{
_status = $"Running Debug daemon (PID {_daemon.Id})";
Changed?.Invoke();
}
ApplyClientEnvironment();
TrySubmitPendingClockAction();
}
else if (TryTakeDaemonExit(out var exitCode))
{
_status = $"Daemon exited ({exitCode})";
_lastError = ReadTail(ErrorLogPath, 24);
Changed?.Invoke();
}
}
private static void OnPauseStateChanged(PauseState state)
{
if (!EditorApplication.isPlaying || !TryRefreshDaemon()) return;
SubmitClockAction(state == PauseState.Paused
? "simulation.pause"
: "simulation.rate.realtime");
}
private static void TrySubmitPendingClockAction()
{
if (string.IsNullOrWhiteSpace(_pendingClockAction) || !EditorApplication.isPlaying) return;
var bootstrap = UnityEngine.Object.FindFirstObjectByType<EveUnityPlayableWorldClientBootstrap>();
var host = bootstrap?.Host;
var entityId = host?.ActiveWorld?.PlayerEntityId;
if (host == null || string.IsNullOrWhiteSpace(entityId) || host.InputCapability == null) return;
try
{
var request = host.SubmitAdvertisedActionIntent(entityId, _pendingClockAction);
_status = $"Submitted {_pendingClockAction} ({request.CommandId})";
_pendingClockAction = "";
Changed?.Invoke();
}
catch (Exception exception)
{
_lastError = $"Could not submit daemon clock action '{_pendingClockAction}': {exception.Message}";
_pendingClockAction = "";
Changed?.Invoke();
}
}
private static bool TryRefreshDaemon()
{
if (_daemon == null)
TryAttachPidFile();
if (_daemon == null) return false;
try
{
if (_daemon.HasExited) return false;
if (string.Equals(_daemon.ProcessName, "Aetheria.State.Daemon", StringComparison.OrdinalIgnoreCase))
return true;
}
catch (InvalidOperationException)
{
}
catch (System.ComponentModel.Win32Exception)
{
}
ClearDaemonReference();
return false;
}
private static void ClearDaemonReference()
{
_daemon?.Dispose();
_daemon = null;
SessionState.EraseInt(ProcessIdSessionKey);
}
private static bool TryTakeDaemonExit(out int exitCode)
{
exitCode = 0;
if (_daemon == null) return false;
try
{
if (!_daemon.HasExited) return false;
exitCode = _daemon.ExitCode;
}
catch (InvalidOperationException)
{
ClearDaemonReference();
return false;
}
catch (System.ComponentModel.Win32Exception)
{
ClearDaemonReference();
return false;
}
ClearDaemonReference();
return true;
}
private static void Reattach()
{
var processId = SessionState.GetInt(ProcessIdSessionKey, -1);
if (processId > 0 && TryAttach(processId, "Reattached to")) return;
SessionState.EraseInt(ProcessIdSessionKey);
TryAttachPidFile();
}
private static void TryAttachPidFile()
{
try
{
if (!File.Exists(PidPath) ||
!int.TryParse(File.ReadAllText(PidPath).Trim(), out var processId))
return;
TryAttach(processId, "Attached to");
}
catch (IOException)
{
// The launcher replaces the PID file atomically; the next liveness check retries.
}
}
private static bool TryAttach(int processId, string statusPrefix)
{
try
{
var process = Process.GetProcessById(processId);
if (process.HasExited ||
!string.Equals(process.ProcessName, "Aetheria.State.Daemon", StringComparison.OrdinalIgnoreCase))
{
process.Dispose();
return false;
}
_daemon?.Dispose();
_daemon = process;
SessionState.SetInt(ProcessIdSessionKey, processId);
_status = $"{statusPrefix} Debug daemon (PID {processId})";
Changed?.Invoke();
return true;
}
catch (ArgumentException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
catch (System.ComponentModel.Win32Exception)
{
return false;
}
}
private static bool IsEndpointReady()
{
if (!File.Exists(LogPath)) return false;
try
{
var marker = $"Aetheria client CultMesh endpoint: {Endpoint}";
using var stream = new FileStream(LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
return reader.ReadToEnd().Contains(marker, StringComparison.Ordinal);
}
catch (IOException)
{
return false;
}
}
private static void ApplyClientEnvironment()
{
Environment.SetEnvironmentVariable("EVEUNITY_RENDEZVOUS_ENDPOINT", Endpoint);
Environment.SetEnvironmentVariable("EVEUNITY_SURFACE_ID", "aetheria.pilot");
Environment.SetEnvironmentVariable("EVEUNITY_ASSET_CACHE_PATH",
Path.Combine(ProjectRoot, "Aetheria.Unity", "Build", "AssetCache"));
}
private static void LaunchDaemon()
{
if (!File.Exists(DaemonExePath))
throw new FileNotFoundException("Prepared Aetheria Debug daemon is missing. Use Build daemon first.", DaemonExePath);
if (!Directory.Exists(DotNetRootPath))
throw new DirectoryNotFoundException(
$"The system .NET runtime root required by the Aetheria daemon is missing: {DotNetRootPath}");
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 startInfo = new ProcessStartInfo(DaemonExePath, arguments)
{
WorkingDirectory = ProjectRoot,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
startInfo.EnvironmentVariables["DOTNET_ROOT"] = DotNetRootPath;
startInfo.EnvironmentVariables["DOTNET_ROOT_X64"] = DotNetRootPath;
startInfo.EnvironmentVariables.Remove("DOTNET_HOST_PATH");
var daemon = Process.Start(startInfo) ??
throw new InvalidOperationException("Aetheria Debug daemon process did not start.");
daemon.EnableRaisingEvents = true;
daemon.Exited += (_, __) =>
{
try
{
AppendLine(
ErrorLogPath,
$"Aetheria Debug daemon PID {daemon.Id} exited with code {daemon.ExitCode} at {DateTimeOffset.UtcNow:O}.");
}
catch (InvalidOperationException)
{
}
};
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;
try { File.AppendAllText(path, value + Environment.NewLine); }
catch (IOException) { }
}
private static string ReadTail(string path, int maximumLines)
{
if (!File.Exists(path)) return "";
try
{
var lines = File.ReadAllLines(path);
return string.Join(Environment.NewLine, lines, Math.Max(0, lines.Length - maximumLines),
Math.Min(maximumLines, lines.Length));
}
catch (IOException exception)
{
return exception.Message;
}
}
private static void OpenFile(string path)
{
if (File.Exists(path)) EditorUtility.OpenWithDefaultApp(path);
else _lastError = $"File does not exist yet: {path}";
Changed?.Invoke();
}
private static string Quote(string value) => $"\"{value.Replace("\"", "\\\"")}\"";
private static string ResolveSibling(params string[] names)
{
var projectsRoot = Directory.GetParent(ProjectRoot)?.FullName ?? ProjectRoot;
foreach (var name in names)
{
var candidate = Path.Combine(projectsRoot, name);
if (Directory.Exists(candidate)) return candidate;
}
return Path.Combine(projectsRoot, names[0]);
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); }
catch (IOException) { }
}
}
}