Make Unity own daemon readiness
This commit is contained in:
parent
f2f41ce164
commit
b312bfec3d
5 changed files with 155 additions and 63 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue