Aetheria/Aetheria.Unity/Assets/Editor/AetheriaDaemonDevelopmentWindow.cs

621 lines
25 KiB
C#
Raw Normal View History

using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using GameCult.Eve.UnityScene;
using UnityEditor;
2026-07-18 16:43:50 +01:00
using UnityEditor.SceneManagement;
using UnityEngine;
2026-07-18 16:43:50 +01:00
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";
private const string AutoStartPreference = "Aetheria.DaemonDevelopment.AutoStart";
private const string StopAfterPlayPreference = "Aetheria.DaemonDevelopment.StopAfterPlay";
2026-07-18 16:43:50 +01:00
internal const string ClientScenePath = "Assets/Aetheria.unity";
private Vector2 _scroll;
[MenuItem(MenuPath)]
public static void Open() => GetWindow<AetheriaDaemonDevelopmentWindow>("Aetheria Daemon");
2026-07-18 16:43:50 +01:00
public static void StartAndPlay()
{
Open();
EnsureClientSceneLoaded();
AetheriaDaemonDevelopmentController.Start(enterPlayWhenReady: true);
}
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);
var autoStart = EditorPrefs.GetBool(AutoStartPreference, true);
var nextAutoStart = EditorGUILayout.Toggle("Start before Play", autoStart);
if (nextAutoStart != autoStart)
EditorPrefs.SetBool(AutoStartPreference, nextAutoStart);
var stopAfterPlay = EditorPrefs.GetBool(StopAfterPlayPreference, false);
var nextStopAfterPlay = EditorGUILayout.Toggle("Stop after Play", stopAfterPlay);
if (nextStopAfterPlay != stopAfterPlay)
EditorPrefs.SetBool(StopAfterPlayPreference, nextStopAfterPlay);
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);
2026-07-18 16:43:50 +01:00
if (GUILayout.Button("Open Aetheria client scene"))
OpenClientScene();
EditorGUILayout.Space(4);
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = !AetheriaDaemonDevelopmentController.IsStarting &&
!AetheriaDaemonDevelopmentController.IsRunning;
if (GUILayout.Button("Start daemon"))
AetheriaDaemonDevelopmentController.Start(enterPlayWhenReady: false);
if (GUILayout.Button("Start & Play"))
2026-07-18 16:43:50 +01:00
StartAndPlay();
GUI.enabled = AetheriaDaemonDevelopmentController.IsRunning ||
AetheriaDaemonDevelopmentController.IsStarting;
if (GUILayout.Button("Stop"))
AetheriaDaemonDevelopmentController.Stop();
GUI.enabled = true;
}
using (new EditorGUILayout.HorizontalScope())
{
GUI.enabled = AetheriaDaemonDevelopmentController.IsRunning;
if (GUILayout.Button("Restart"))
AetheriaDaemonDevelopmentController.Restart();
GUI.enabled = !AetheriaDaemonDevelopmentController.IsRunning &&
!AetheriaDaemonDevelopmentController.IsStarting;
if (GUILayout.Button("Reimport state & start"))
AetheriaDaemonDevelopmentController.Start(enterPlayWhenReady: false, 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 const string AutoStartPreference = "Aetheria.DaemonDevelopment.AutoStart";
private const string StopAfterPlayPreference = "Aetheria.DaemonDevelopment.StopAfterPlay";
private static Process _launcher;
private static Process _daemon;
private static bool _enterPlayWhenReady;
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.playModeStateChanged += OnPlayModeStateChanged;
EditorApplication.pauseStateChanged += OnPauseStateChanged;
Reattach();
ApplyClientEnvironment();
}
public static bool IsRunning => TryRefreshDaemon();
public static bool IsStarting => _launcher != null && !_launcher.HasExited;
public static string Status => _status;
public static string LastError => _lastError;
public static string Endpoint => $"rudp://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");
2026-07-19 01:05:56 +01:00
public static string CultLibRoot => ResolveSibling("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");
public static void Start(bool enterPlayWhenReady, bool forceImport = false)
{
if (TryRefreshDaemon())
{
ApplyClientEnvironment();
if (enterPlayWhenReady && !EditorApplication.isPlaying)
EditorApplication.isPlaying = true;
return;
}
if (IsStarting)
{
_enterPlayWhenReady |= enterPlayWhenReady;
return;
}
Directory.CreateDirectory(ArtifactsRoot);
File.Delete(PidPath);
_lastError = "";
_enterPlayWhenReady = enterPlayWhenReady;
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)} -Port {Port} " +
$"-PidFile {Quote(PidPath)} -LogFile {Quote(LogPath)} -ErrorLogFile {Quote(ErrorLogPath)} " +
$"-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()
{
_enterPlayWhenReady = false;
_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(enterPlayWhenReady: EditorApplication.isPlaying);
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})";
_enterPlayWhenReady = false;
Changed?.Invoke();
return;
}
}
if (TryRefreshDaemon() && IsEndpointReady())
{
if (!_status.StartsWith("Running", StringComparison.Ordinal))
{
_status = $"Running Debug daemon (PID {_daemon.Id})";
Changed?.Invoke();
}
ApplyClientEnvironment();
if (_enterPlayWhenReady && !EditorApplication.isPlayingOrWillChangePlaymode)
{
_enterPlayWhenReady = false;
EditorApplication.isPlaying = true;
}
TrySubmitPendingClockAction();
}
else if (TryTakeDaemonExit(out var exitCode))
{
_status = $"Daemon exited ({exitCode})";
_lastError = ReadTail(ErrorLogPath, 24);
Changed?.Invoke();
}
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.ExitingEditMode &&
2026-07-18 16:43:50 +01:00
!AetheriaDaemonDevelopmentWindow.IsClientSceneLoaded())
{
EditorApplication.isPlaying = false;
EditorApplication.delayCall += PrepareClientSceneAndPlay;
}
else if (state == PlayModeStateChange.ExitingEditMode &&
EditorPrefs.GetBool(AutoStartPreference, true) && !TryRefreshDaemon())
{
EditorApplication.isPlaying = false;
Start(enterPlayWhenReady: true);
}
else if (state == PlayModeStateChange.EnteredEditMode &&
EditorPrefs.GetBool(StopAfterPlayPreference, false))
{
Stop();
}
}
2026-07-18 16:43:50 +01:00
private static void PrepareClientSceneAndPlay()
{
AetheriaDaemonDevelopmentWindow.EnsureClientSceneLoaded();
if (EditorPrefs.GetBool(AutoStartPreference, true))
Start(enterPlayWhenReady: true);
else
EditorApplication.isPlaying = true;
}
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 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) { }
}
}
}