Wire replica sync into client target shell

This commit is contained in:
Meta Vens 2026-06-17 14:29:41 +01:00
commit a04df8a585
9 changed files with 312 additions and 6 deletions

View file

@ -17,6 +17,7 @@
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeVerseDiscovery.cs" Link="AetheriaRuntimeVerseDiscovery.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeStateBoundary.cs" Link="AetheriaRuntimeStateBoundary.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeStateBoot.cs" Link="AetheriaRuntimeStateBoot.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeVerseReplicaBridge.cs" Link="AetheriaRuntimeVerseReplicaBridge.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeStateReader.cs" Link="AetheriaRuntimeStateReader.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeBehaviorMetadata.cs" Link="AetheriaRuntimeBehaviorMetadata.cs" />
<Compile Include="..\Packages\org.gamecult.aetheria.state\Runtime\AetheriaRuntimeBehaviorKinds.cs" Link="AetheriaRuntimeBehaviorKinds.cs" />

View file

@ -3878,6 +3878,7 @@ static void RequireClientTargetBootAuthority(string root)
var bootPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeStateBoot.cs");
var clientTargetStorePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetStore.cs");
var verseDiscoveryPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseDiscovery.cs");
var replicaBridgePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseReplicaBridge.cs");
var bootstrapPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveRuntimeBootstrap.cs");
var presenterPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.eve-runtime", "Runtime", "AetheriaEveSurfacePresenter.cs");
var actionGameManagerPath = Path.Combine(root, "Assets", "Scripts", "Gameplay", "ActionGameManager.cs");
@ -3890,6 +3891,7 @@ static void RequireClientTargetBootAuthority(string root)
bootPath,
clientTargetStorePath,
verseDiscoveryPath,
replicaBridgePath,
bootstrapPath,
presenterPath,
actionGameManagerPath,
@ -3912,6 +3914,7 @@ static void RequireClientTargetBootAuthority(string root)
var boot = File.ReadAllText(bootPath);
var clientTargetStore = File.ReadAllText(clientTargetStorePath);
var verseDiscovery = File.ReadAllText(verseDiscoveryPath);
var replicaBridge = File.ReadAllText(replicaBridgePath);
var bootstrap = File.ReadAllText(bootstrapPath);
var presenter = File.ReadAllText(presenterPath);
var actionGameManager = File.ReadAllText(actionGameManagerPath);
@ -3922,7 +3925,8 @@ static void RequireClientTargetBootAuthority(string root)
"AetheriaRuntimeClientTargetStore.cs",
"AetheriaRuntimeVerseDiscovery.cs",
"AetheriaRuntimeStateBoundary.cs",
"AetheriaRuntimeStateBoot.cs"
"AetheriaRuntimeStateBoot.cs",
"AetheriaRuntimeVerseReplicaBridge.cs"
};
var missingUnityFacadeSymbols = requiredUnityFacadeSymbols
.Where(symbol => !unityFacadeProject.Contains(symbol, StringComparison.Ordinal))
@ -3967,6 +3971,8 @@ static void RequireClientTargetBootAuthority(string root)
"LastDiscoveryAtUtc",
"LastDiscoveryError",
"ReplicaStateFilePath",
"LastReplicaSyncAtUtc",
"LastReplicaSyncError",
"ReadOrInitialize",
"CreateDefault",
"Write(string clientTargetPath, AetheriaRuntimeClientTargetDocument document)",
@ -3994,6 +4000,8 @@ static void RequireClientTargetBootAuthority(string root)
"LastDiscoveryAtUtc",
"LastDiscoveryError",
"ReplicaStateFilePath",
"LastReplicaSyncAtUtc",
"LastReplicaSyncError",
"TargetLabel",
"AetheriaRuntimeClientTargetStore.ReadOrInitialize",
"AetheriaRuntimeStateBoundary.ResolveStatePathOverride()",
@ -4032,6 +4040,25 @@ static void RequireClientTargetBootAuthority(string root)
string.Join(", ", missingVerseDiscoverySymbols));
}
var requiredReplicaBridgeSymbols = new[]
{
"public static class AetheriaRuntimeVerseReplicaBridge",
"AetheriaRuntimeVerseReplicaSyncResult",
"Aetheria.State.Replica",
"Sync(",
"ProcessStartInfo",
"AetheriaRuntimeStateBoundary.GetReplicaStateFilePath"
};
var missingReplicaBridgeSymbols = requiredReplicaBridgeSymbols
.Where(symbol => !replicaBridge.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingReplicaBridgeSymbols.Length > 0)
{
throw new InvalidOperationException(
"Runtime Verse replica bridge is incomplete: " +
string.Join(", ", missingReplicaBridgeSymbols));
}
var requiredActionGameManagerSymbols = new[]
{
"AetheriaRuntimeStateBoot.Inspect(GameDataDirectory)",
@ -4082,6 +4109,8 @@ static void RequireClientTargetBootAuthority(string root)
"stateBoot.LastDiscoveryAtUtc",
"stateBoot.LastDiscoveryError",
"stateBoot.ReplicaStateFilePath",
"stateBoot.LastReplicaSyncAtUtc",
"stateBoot.LastReplicaSyncError",
"\"Client Target\"",
"\"Transport\"",
"\"Target Source\""
@ -4232,6 +4261,7 @@ static void RequireVerseSettingsShellAndBridge(string root)
var verseHostCommandsPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseHostCommands.cs");
var clientTargetStorePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeClientTargetStore.cs");
var verseDiscoveryPath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseDiscovery.cs");
var replicaBridgePath = Path.Combine(root, "Packages", "org.gamecult.aetheria.state", "Runtime", "AetheriaRuntimeVerseReplicaBridge.cs");
var mainMenuPath = Path.Combine(root, "Assets", "Scripts", "UI", "MainMenu.cs");
var commandBridgePath = Path.Combine(root, "Aetheria.State", "AetheriaEveCommandBridge.cs");
var drainStatusPath = Path.Combine(root, "Aetheria.State", "Documents", "AetheriaEveCommandDrainStatus.cs");
@ -4248,6 +4278,7 @@ static void RequireVerseSettingsShellAndBridge(string root)
verseHostCommandsPath,
clientTargetStorePath,
verseDiscoveryPath,
replicaBridgePath,
mainMenuPath,
commandBridgePath,
drainStatusPath,
@ -4274,6 +4305,7 @@ static void RequireVerseSettingsShellAndBridge(string root)
var verseHostCommands = File.ReadAllText(verseHostCommandsPath);
var clientTargetStore = File.ReadAllText(clientTargetStorePath);
var verseDiscovery = File.ReadAllText(verseDiscoveryPath);
var replicaBridge = File.ReadAllText(replicaBridgePath);
var mainMenu = File.ReadAllText(mainMenuPath);
var commandBridge = File.ReadAllText(commandBridgePath);
var drainStatus = File.ReadAllText(drainStatusPath);
@ -4285,7 +4317,8 @@ static void RequireVerseSettingsShellAndBridge(string root)
{
"AetheriaRuntimeClientTargetCommands.cs",
"AetheriaRuntimeClientTargetSurfaceBuilder.cs",
"AetheriaRuntimeVerseDiscovery.cs"
"AetheriaRuntimeVerseDiscovery.cs",
"AetheriaRuntimeVerseReplicaBridge.cs"
};
var missingUnityFacadeSymbols = requiredUnityFacadeSymbols
.Where(symbol => !unityFacadeProject.Contains(symbol, StringComparison.Ordinal))
@ -4314,6 +4347,7 @@ static void RequireVerseSettingsShellAndBridge(string root)
"SetDiscoveryEndpoints",
"DiscoverVerses",
"SelectDiscoveredVerse",
"SyncReplica",
"IsKnown"
};
var missingClientTargetCommandSymbols = requiredClientTargetCommandSymbols
@ -4351,10 +4385,12 @@ static void RequireVerseSettingsShellAndBridge(string root)
"\"Verse Discovery\"",
"\"Daemon Verse Host\"",
"\"Replica State File\"",
"\"Replica Sync\"",
"AetheriaRuntimeClientTargetCommands.SetStateFilePath",
"AetheriaRuntimeClientTargetCommands.SetDiscoveryEndpoints",
"AetheriaRuntimeClientTargetCommands.DiscoverVerses",
"AetheriaRuntimeClientTargetCommands.SelectDiscoveredVerse",
"AetheriaRuntimeClientTargetCommands.SyncReplica",
"AetheriaRuntimeVerseHostCommands.CycleVisibility",
"AetheriaRuntimeVerseHostCommands.Refresh"
};
@ -4390,6 +4426,23 @@ static void RequireVerseSettingsShellAndBridge(string root)
string.Join(", ", missingVerseDiscoverySymbols));
}
var requiredReplicaBridgeSymbols = new[]
{
"public static class AetheriaRuntimeVerseReplicaBridge",
"ProcessStartInfo",
"Aetheria.State.Replica",
"Sync("
};
var missingReplicaBridgeSymbols = requiredReplicaBridgeSymbols
.Where(symbol => !replicaBridge.Contains(symbol, StringComparison.Ordinal))
.ToArray();
if (missingReplicaBridgeSymbols.Length > 0)
{
throw new InvalidOperationException(
"Verse replica bridge no longer exposes the sync transport used by the Unity shell: " +
string.Join(", ", missingReplicaBridgeSymbols));
}
var requiredMainMenuSymbols = new[]
{
"VerseSettingsShellSurfaceId",
@ -4402,6 +4455,7 @@ static void RequireVerseSettingsShellAndBridge(string root)
"TryQueueVerseHostCommand(",
"AetheriaRuntimeClientTargetStore.Update(",
"AetheriaRuntimeVerseDiscovery.RefreshClientTarget(",
"AetheriaRuntimeVerseReplicaBridge.Sync(",
"ParseDiscoveryEndpoints(",
"new EveSurfaceCommandRequest(",
"AetheriaRuntimeVerseHostCommands.SurfaceId"

View file

@ -423,6 +423,10 @@ truth. The selected remote client target resolves to that replica path. The
daemon still owns accepted state; the replica is only the local read surface
Unity lowers from.
The Unity Verse-settings shell can also trigger the same one-shot sync through
the `Aetheria.State.Replica` bridge when the repo-local tool is available. That
keeps target selection local while the replica organ owns daemon transport.
`Aetheria.State.ApplyPending` is the smaller local operator applicator for
queued Unity runtime commits and Eve commands when the server host is not being
used. It also republishes the typed drain status, operations surface,

View file

@ -458,6 +458,8 @@ public class MainMenu : MonoBehaviour
stateBoot.DiscoveredVerses ?? Array.Empty<AetheriaRuntimeDiscoveredVerse>(),
stateBoot.LastDiscoveryAtUtc,
stateBoot.LastDiscoveryError,
stateBoot.LastReplicaSyncAtUtc,
stateBoot.LastReplicaSyncError,
stateBoot.TargetSource,
stateBoot.SupportsLocalStateFileRead,
stateBoot.FailureMessage,
@ -626,6 +628,41 @@ public class MainMenu : MonoBehaviour
return true;
}
if (string.Equals(command, AetheriaRuntimeClientTargetCommands.SyncReplica, StringComparison.Ordinal))
{
var target = AetheriaRuntimeClientTargetStore.ReadOrInitialize(clientTargetPath, defaultStateFilePath);
var syncedAtUtc = DateTime.UtcNow.ToString("O");
try
{
var result = AetheriaRuntimeVerseReplicaBridge.Sync(gameDataDirectory, target);
AetheriaRuntimeClientTargetStore.Update(
clientTargetPath,
defaultStateFilePath,
document =>
{
document.ReplicaStateFilePath = result.ReplicaStateFilePath;
document.LastReplicaSyncAtUtc = syncedAtUtc;
document.LastReplicaSyncError = "";
document.UpdatedAtUtc = syncedAtUtc;
});
}
catch (Exception ex)
{
AetheriaRuntimeClientTargetStore.Update(
clientTargetPath,
defaultStateFilePath,
document =>
{
document.LastReplicaSyncAtUtc = syncedAtUtc;
document.LastReplicaSyncError = ex.Message ?? ex.GetType().Name;
document.UpdatedAtUtc = syncedAtUtc;
});
throw;
}
return true;
}
AetheriaRuntimeClientTargetStore.Update(
clientTargetPath,
defaultStateFilePath,
@ -637,15 +674,19 @@ public class MainMenu : MonoBehaviour
document.TargetKind = string.Equals(document.TargetKind, AetheriaRuntimeClientTargetKinds.CultMeshVerse, StringComparison.Ordinal)
? AetheriaRuntimeClientTargetKinds.StateFile
: AetheriaRuntimeClientTargetKinds.CultMeshVerse;
document.LastReplicaSyncError = "";
break;
case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetTitle, StringComparison.Ordinal):
document.Title = ReadPayloadValue(payload, "value");
break;
case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetVerseId, StringComparison.Ordinal):
document.VerseId = ReadPayloadValue(payload, "value");
document.ReplicaStateFilePath = AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, document.VerseId);
document.LastReplicaSyncError = "";
break;
case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetCultMeshAddress, StringComparison.Ordinal):
document.CultMeshAddress = ReadPayloadValue(payload, "value");
document.LastReplicaSyncError = "";
break;
case var _ when string.Equals(command, AetheriaRuntimeClientTargetCommands.SetStateFilePath, StringComparison.Ordinal):
document.StateFilePath = ReadPayloadValue(payload, "value");
@ -659,8 +700,10 @@ public class MainMenu : MonoBehaviour
document.Title = ReadPayloadValue(payload, "title");
document.VerseId = ReadPayloadValue(payload, "verseId");
document.CultMeshAddress = ReadPayloadValue(payload, "cultMeshAddress");
document.ReplicaStateFilePath = AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, document.VerseId);
document.DiscoveryEndpoints = ParseDiscoveryEndpoints(ReadPayloadValue(payload, "discoveryEndpoints"));
document.LastDiscoveryError = "";
document.LastReplicaSyncError = "";
break;
}

View file

@ -12,6 +12,7 @@ namespace GameCult.Aetheria.State.Unity
public const string SetDiscoveryEndpoints = "aetheria.client_target.discovery_endpoints.set";
public const string DiscoverVerses = "aetheria.client_target.discovery.refresh";
public const string SelectDiscoveredVerse = "aetheria.client_target.discovery.select";
public const string SyncReplica = "aetheria.client_target.replica.sync";
public static bool IsKnown(string command)
{
@ -23,7 +24,8 @@ namespace GameCult.Aetheria.State.Unity
command == SetStateFilePath ||
command == SetDiscoveryEndpoints ||
command == DiscoverVerses ||
command == SelectDiscoveredVerse;
command == SelectDiscoveredVerse ||
command == SyncReplica;
}
}
}

View file

@ -34,6 +34,8 @@ namespace GameCult.Aetheria.State.Unity
[Key(9)] public string LastDiscoveryAtUtc { get; set; } = "";
[Key(10)] public string LastDiscoveryError { get; set; } = "";
[Key(11)] public string ReplicaStateFilePath { get; set; } = "";
[Key(12)] public string LastReplicaSyncAtUtc { get; set; } = "";
[Key(13)] public string LastReplicaSyncError { get; set; } = "";
}
[MessagePackObject]
@ -96,6 +98,8 @@ namespace GameCult.Aetheria.State.Unity
document.LastDiscoveryAtUtc ??= "";
document.LastDiscoveryError ??= "";
document.ReplicaStateFilePath = NormalizeReplicaStateFilePath(document, clientTargetPath, document.ReplicaStateFilePath);
document.LastReplicaSyncAtUtc ??= "";
document.LastReplicaSyncError ??= "";
foreach (var verse in document.DiscoveredVerses)
{
if (verse == null)

View file

@ -17,6 +17,8 @@ namespace GameCult.Aetheria.State.Unity
IReadOnlyList<AetheriaRuntimeDiscoveredVerse> discoveredVerses,
string lastDiscoveryAtUtc,
string lastDiscoveryError,
string lastReplicaSyncAtUtc,
string lastReplicaSyncError,
string targetSource,
bool supportsLocalStateFileRead,
string bootFailureMessage,
@ -36,6 +38,8 @@ namespace GameCult.Aetheria.State.Unity
DiscoveredVerses = discoveredVerses ?? Array.Empty<AetheriaRuntimeDiscoveredVerse>();
LastDiscoveryAtUtc = lastDiscoveryAtUtc ?? "";
LastDiscoveryError = lastDiscoveryError ?? "";
LastReplicaSyncAtUtc = lastReplicaSyncAtUtc ?? "";
LastReplicaSyncError = lastReplicaSyncError ?? "";
TargetSource = targetSource ?? "";
SupportsLocalStateFileRead = supportsLocalStateFileRead;
BootFailureMessage = bootFailureMessage ?? "";
@ -56,6 +60,8 @@ namespace GameCult.Aetheria.State.Unity
public IReadOnlyList<AetheriaRuntimeDiscoveredVerse> DiscoveredVerses { get; }
public string LastDiscoveryAtUtc { get; }
public string LastDiscoveryError { get; }
public string LastReplicaSyncAtUtc { get; }
public string LastReplicaSyncError { get; }
public string TargetSource { get; }
public bool SupportsLocalStateFileRead { get; }
public string BootFailureMessage { get; }
@ -98,6 +104,8 @@ namespace GameCult.Aetheria.State.Unity
"",
"",
"",
"",
"",
supportsLocalStateFileRead: true,
bootFailureMessage: "",
hostTitle: "",
@ -114,11 +122,12 @@ namespace GameCult.Aetheria.State.Unity
? "Make Private"
: "Make Public";
var lastDiscoveryLabel = string.IsNullOrWhiteSpace(state.LastDiscoveryAtUtc) ? "never" : state.LastDiscoveryAtUtc;
var lastReplicaSyncLabel = string.IsNullOrWhiteSpace(state.LastReplicaSyncAtUtc) ? "never" : state.LastReplicaSyncAtUtc;
var summaryNote = !state.SupportsLocalStateFileRead && !string.IsNullOrWhiteSpace(state.BootFailureMessage)
? state.BootFailureMessage
: string.Equals(state.TargetSource, "state-path-override", StringComparison.Ordinal)
? "AETHERIA_STATE_PATH is overriding the persisted client target. Update the environment if you want boot to follow the saved target again."
: "Client target edits persist in aetheria-client.cc. Verse discovery and selection mutate the same typed owner. Verse visibility changes queue provider-owned Eve commands for the daemon bridge.";
: "Client target edits persist in aetheria-client.cc. Verse discovery and selection mutate the same typed owner. Verse visibility changes queue provider-owned Eve commands for the daemon bridge. Remote Verse targets hydrate a cache-only local replica before Unity reads them.";
var discoveryChildren = new List<AetheriaRuntimeSurfaceComponent>
{
@ -217,6 +226,13 @@ namespace GameCult.Aetheria.State.Unity
"aetheria.clientTarget.target.replicaStateFilePath",
"Replica State File",
state.TargetReplicaStateFilePath),
Metric(
"aetheria.clientTarget.target.replicaSyncAt",
"Replica Sync",
lastReplicaSyncLabel),
Text(
"aetheria.clientTarget.target.replicaSyncError",
state.LastReplicaSyncError),
ButtonRow(
"aetheria.clientTarget.target.actions",
Button(
@ -225,6 +241,10 @@ namespace GameCult.Aetheria.State.Unity
? "Use Local State File"
: "Use CultMesh Verse",
AetheriaRuntimeClientTargetCommands.CycleTargetKind),
Button(
"aetheria.clientTarget.target.syncReplica",
"Sync Replica",
AetheriaRuntimeClientTargetCommands.SyncReplica),
Button(
"aetheria.clientTarget.target.refresh",
"Refresh",
@ -297,6 +317,10 @@ namespace GameCult.Aetheria.State.Unity
AetheriaRuntimeClientTargetCommands.SelectDiscoveredVerse,
"Select Discovered Verse",
"unity-uitoolkit"),
new AetheriaRuntimeSurfaceCommandTemplate(
AetheriaRuntimeClientTargetCommands.SyncReplica,
"Sync Replica",
"unity-uitoolkit"),
new AetheriaRuntimeSurfaceCommandTemplate(
AetheriaRuntimeVerseHostCommands.CycleVisibility,
"Toggle Visibility",

View file

@ -21,7 +21,9 @@ namespace GameCult.Aetheria.State.Unity
string[] discoveryEndpoints,
AetheriaRuntimeDiscoveredVerse[] discoveredVerses,
string lastDiscoveryAtUtc,
string lastDiscoveryError)
string lastDiscoveryError,
string lastReplicaSyncAtUtc,
string lastReplicaSyncError)
{
ClientTargetPath = clientTargetPath;
TargetKind = targetKind;
@ -38,6 +40,8 @@ namespace GameCult.Aetheria.State.Unity
DiscoveredVerses = discoveredVerses ?? Array.Empty<AetheriaRuntimeDiscoveredVerse>();
LastDiscoveryAtUtc = lastDiscoveryAtUtc ?? "";
LastDiscoveryError = lastDiscoveryError ?? "";
LastReplicaSyncAtUtc = lastReplicaSyncAtUtc ?? "";
LastReplicaSyncError = lastReplicaSyncError ?? "";
}
public string ClientTargetPath { get; }
@ -55,6 +59,8 @@ namespace GameCult.Aetheria.State.Unity
public AetheriaRuntimeDiscoveredVerse[] DiscoveredVerses { get; }
public string LastDiscoveryAtUtc { get; }
public string LastDiscoveryError { get; }
public string LastReplicaSyncAtUtc { get; }
public string LastReplicaSyncError { get; }
public string TargetLabel =>
string.IsNullOrWhiteSpace(Title)
@ -134,7 +140,9 @@ namespace GameCult.Aetheria.State.Unity
target.DiscoveryEndpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray() ?? Array.Empty<string>(),
target.DiscoveredVerses ?? Array.Empty<AetheriaRuntimeDiscoveredVerse>(),
target.LastDiscoveryAtUtc ?? "",
target.LastDiscoveryError ?? "");
target.LastDiscoveryError ?? "",
target.LastReplicaSyncAtUtc ?? "",
target.LastReplicaSyncError ?? "");
}
private static string BuildTargetLabel(string title, string verseId)

View file

@ -0,0 +1,166 @@
using System.Diagnostics;
using System.IO;
using System.Text;
namespace GameCult.Aetheria.State.Unity
{
public readonly struct AetheriaRuntimeVerseReplicaSyncResult
{
public AetheriaRuntimeVerseReplicaSyncResult(
string replicaStateFilePath,
string standardOutput,
string standardError)
{
ReplicaStateFilePath = replicaStateFilePath ?? "";
StandardOutput = standardOutput ?? "";
StandardError = standardError ?? "";
}
public string ReplicaStateFilePath { get; }
public string StandardOutput { get; }
public string StandardError { get; }
}
public static class AetheriaRuntimeVerseReplicaBridge
{
public static AetheriaRuntimeVerseReplicaSyncResult Sync(
DirectoryInfo gameDataDirectory,
AetheriaRuntimeClientTargetDocument target,
TimeSpan? timeout = null)
{
if (gameDataDirectory == null) throw new ArgumentNullException(nameof(gameDataDirectory));
if (target == null) throw new ArgumentNullException(nameof(target));
var endpoint = target.CultMeshAddress ?? "";
if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("Cannot sync a Verse replica without a CultMesh endpoint.");
var verseId = string.IsNullOrWhiteSpace(target.VerseId) ? "unknown-verse" : target.VerseId;
var replicaStateFilePath = string.IsNullOrWhiteSpace(target.ReplicaStateFilePath)
? AetheriaRuntimeStateBoundary.GetReplicaStateFilePath(gameDataDirectory, verseId)
: Path.GetFullPath(target.ReplicaStateFilePath);
var repoRoot = gameDataDirectory.Parent?.FullName;
if (string.IsNullOrWhiteSpace(repoRoot))
throw new InvalidOperationException("Cannot resolve the Aetheria repo root from the GameData directory.");
Directory.CreateDirectory(Path.GetDirectoryName(replicaStateFilePath) ?? gameDataDirectory.FullName);
var startInfo = BuildStartInfo(repoRoot, endpoint, replicaStateFilePath, verseId);
using var process = new Process { StartInfo = startInfo };
var output = new StringBuilder();
var error = new StringBuilder();
process.OutputDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
output.AppendLine(eventArgs.Data);
};
process.ErrorDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
error.AppendLine(eventArgs.Data);
};
if (!process.Start())
throw new InvalidOperationException("Failed to start the Aetheria Verse replica sync process.");
process.BeginOutputReadLine();
process.BeginErrorReadLine();
var waitTimeout = timeout ?? TimeSpan.FromSeconds(90);
if (!process.WaitForExit((int)Math.Ceiling(waitTimeout.TotalMilliseconds)))
{
try
{
process.Kill();
}
catch
{
}
throw new TimeoutException(
$"Timed out waiting {waitTimeout.TotalSeconds:0} seconds for the Aetheria Verse replica sync process.");
}
process.WaitForExit();
var outputText = output.ToString().Trim();
var errorText = error.ToString().Trim();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
string.IsNullOrWhiteSpace(errorText)
? (string.IsNullOrWhiteSpace(outputText)
? $"Aetheria Verse replica sync exited with code {process.ExitCode}."
: outputText)
: errorText);
}
return new AetheriaRuntimeVerseReplicaSyncResult(replicaStateFilePath, outputText, errorText);
}
private static ProcessStartInfo BuildStartInfo(
string repoRoot,
string endpoint,
string replicaStateFilePath,
string verseId)
{
var executablePath = Path.Combine(repoRoot, "Aetheria.State.Replica", "bin", "Debug", "net10.0", "Aetheria.State.Replica.exe");
if (File.Exists(executablePath))
{
return CreateStartInfo(
executablePath,
BuildArguments("sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId),
repoRoot);
}
var dllPath = Path.Combine(repoRoot, "Aetheria.State.Replica", "bin", "Debug", "net10.0", "Aetheria.State.Replica.dll");
if (File.Exists(dllPath))
{
return CreateStartInfo(
"dotnet",
BuildArguments(dllPath, "sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId),
repoRoot);
}
var projectPath = Path.Combine(repoRoot, "Aetheria.State.Replica", "Aetheria.State.Replica.csproj");
if (!File.Exists(projectPath))
throw new FileNotFoundException("Cannot find the Aetheria.State.Replica project.", projectPath);
return CreateStartInfo(
"dotnet",
BuildArguments("run", "--project", projectPath, "--", "sync", "--endpoint", endpoint, "--replica", replicaStateFilePath, "--verse-id", verseId),
repoRoot);
}
private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string workingDirectory)
{
return new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
}
private static string BuildArguments(params string[] values)
{
return string.Join(" ", values.Select(QuoteArgument));
}
private static string QuoteArgument(string value)
{
var text = value ?? "";
if (text.Length == 0)
return "\"\"";
if (text.IndexOfAny(new[] { ' ', '\t', '\n', '\r', '"' }) < 0)
return text;
return "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
}
}
}