using System.Diagnostics;
using System.Globalization;
namespace XFE.SeAgent.Cli;
public sealed record GameHostStatus(int Pid, string? ExecutablePath, DateTimeOffset? StartTimeUtc,
bool AgentAvailable, string State, string? WorldPath, string? EvidencePath, string Detail);
public interface IWorldUseGuard
{
IReadOnlyList<GameHostStatus> Inspect();
void AssertUnloaded(string worldPath);
}
public sealed class HostStatus(EndpointDiscovery? discovery = null) : IWorldUseGuard
{
private readonly EndpointDiscovery discovery = discovery ?? new();
public static string GameDataDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SpaceEngineers");
public IReadOnlyList<GameHostStatus> Inspect()
{
var endpoints = discovery.Discover();
var result = new List<GameHostStatus>();
foreach (var process in Process.GetProcesses())
{
using (process)
{
string name;
try { name = process.ProcessName; }
catch (InvalidOperationException) { continue; }
if (!name.Contains("SpaceEngineers", StringComparison.OrdinalIgnoreCase)) continue;
var identity = new LocalProcessInspector().Inspect(process.Id);
bool agent = endpoints.Any(item => item.Pid == process.Id);
var state = new GameHostStatus(process.Id, identity?.ExecutablePath, identity?.StartTimeUtc,
agent, "unknown", null, null, "无法确定游戏正在使用的存档;拒绝离线写入。请使用实时接口,或保存退出游戏。");
if (identity != null && Directory.Exists(GameDataDirectory))
{
// Correlate by process start AND executable in the log header, never just the newest log.
var logs = Directory.EnumerateFiles(GameDataDirectory, "SpaceEngineers_*.log")
.Where(path => LogStartMatches(path, identity.StartTimeUtc)).ToArray();
if (logs.Length == 1)
{
try
{
using var input = new FileStream(logs[0], FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (input.Length <= 256L * 1024 * 1024)
{
using var reader = new StreamReader(input);
state = ParseLog(reader, state, logs[0]);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
result.Add(state);
}
}
// Two instances cannot both be identified by the same log.
foreach (var group in result.Where(x => x.EvidencePath != null).GroupBy(x => x.EvidencePath, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1))
foreach (var host in group.ToArray()) result[result.IndexOf(host)] = host with { State = "unknown", WorldPath = null, Detail = "多个进程匹配同一日志,无法确认存档使用状态。" };
return result.OrderBy(x => x.Pid).ToArray();
}
public void AssertUnloaded(string worldPath) => CheckUnloaded(worldPath, Inspect());
public static void CheckUnloaded(string worldPath, IReadOnlyList<GameHostStatus> hosts)
{
foreach (var host in hosts)
{
if (host.State == "unknown") throw new OfflineWorldException("host_unknown", $"PID {host.Pid}: {host.Detail}");
if (host.WorldPath != null && OfflineWorldStore.SamePath(host.WorldPath, worldPath))
throw new OfflineWorldException("world_in_use", $"目标存档正由 PID {host.Pid} 使用;请使用实时 pb.deploy,或保存卸载后重试。");
}
}
private static bool LogStartMatches(string path, DateTimeOffset processStart)
{
string stamp = Path.GetFileNameWithoutExtension(path)["SpaceEngineers_".Length..];
if (!DateTime.TryParseExact(stamp, "yyyyMMdd_HHmmssfff", CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) return false;
double delta = (DateTime.SpecifyKind(local, DateTimeKind.Local).ToUniversalTime() - processStart.UtcDateTime).TotalSeconds;
return delta >= -2 && delta <= 30;
}
public static GameHostStatus ParseLog(TextReader reader, GameHostStatus host, string logPath)
{
bool executableMatches = false;
string state = "unknown";
string? world = null;
string? line;
while ((line = reader.ReadLine()) != null)
{
const string executableMarker = "Environment.CommandLine: ";
int command = line.IndexOf(executableMarker, StringComparison.Ordinal);
if (command >= 0 && host.ExecutablePath != null)
{
string value = line[(command + executableMarker.Length)..].Trim();
executableMatches = value.StartsWith('"')
? value.StartsWith('"' + host.ExecutablePath + '"', StringComparison.OrdinalIgnoreCase)
: value.Equals(host.ExecutablePath, StringComparison.OrdinalIgnoreCase) || value.StartsWith(host.ExecutablePath + " ", StringComparison.OrdinalIgnoreCase);
}
const string loading = "Loading session: ";
int load = line.IndexOf(loading, StringComparison.Ordinal);
if (load >= 0)
{
string path = line[(load + loading.Length)..].Trim();
state = Path.IsPathFullyQualified(path) ? "loaded_or_loading" : "unknown";
world = state == "unknown" ? null : Path.GetFullPath(path);
}
// Keep the last path protected until unloading is complete.
if (line.Contains("MySession::Unload END", StringComparison.Ordinal)) { state = "unloaded"; world = null; }
}
if (!executableMatches) return host;
return host with { State = state, WorldPath = world, EvidencePath = logPath,
Detail = state == "unknown" ? host.Detail : host.AgentAvailable ? "Agent 已连接;存档状态依据该进程日志。" : "游戏未加载 AgentBridge;可管理未加载的本地存档,实时操作需通过 XFE 加载器启动。" };
}
}
using System.Diagnostics;
using System.Globalization;
namespace XFE.SeAgent.Cli;
public sealed record GameHostStatus(int Pid, string? ExecutablePath, DateTimeOffset? StartTimeUtc,
bool AgentAvailable, string State, string? WorldPath, string? EvidencePath, string Detail);
public interface IWorldUseGuard
{
IReadOnlyList<GameHostStatus> Inspect();
void AssertUnloaded(string worldPath);
}
public sealed class HostStatus(EndpointDiscovery? discovery = null) : IWorldUseGuard
{
private readonly EndpointDiscovery discovery = discovery ?? new();
public static string GameDataDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SpaceEngineers");
public IReadOnlyList<GameHostStatus> Inspect()
{
var endpoints = discovery.Discover();
var result = new List<GameHostStatus>();
foreach (var process in Process.GetProcesses())
{
using (process)
{
string name;
try { name = process.ProcessName; }
catch (InvalidOperationException) { continue; }
if (!name.Contains("SpaceEngineers", StringComparison.OrdinalIgnoreCase)) continue;
var identity = new LocalProcessInspector().Inspect(process.Id);
bool agent = endpoints.Any(item => item.Pid == process.Id);
var state = new GameHostStatus(process.Id, identity?.ExecutablePath, identity?.StartTimeUtc,
agent, "unknown", null, null, "无法确定游戏正在使用的存档;拒绝离线写入。请使用实时接口,或保存退出游戏。");
if (identity != null && Directory.Exists(GameDataDirectory))
{
// Correlate by process start AND executable in the log header, never just the newest log.
var logs = Directory.EnumerateFiles(GameDataDirectory, "SpaceEngineers_*.log")
.Where(path => LogStartMatches(path, identity.StartTimeUtc)).ToArray();
if (logs.Length == 1)
{
try
{
using var input = new FileStream(logs[0], FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
if (input.Length <= 256L * 1024 * 1024)
{
using var reader = new StreamReader(input);
state = ParseLog(reader, state, logs[0]);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
}
}
result.Add(state);
}
}
// Two instances cannot both be identified by the same log.
foreach (var group in result.Where(x => x.EvidencePath != null).GroupBy(x => x.EvidencePath, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1))
foreach (var host in group.ToArray()) result[result.IndexOf(host)] = host with { State = "unknown", WorldPath = null, Detail = "多个进程匹配同一日志,无法确认存档使用状态。" };
return result.OrderBy(x => x.Pid).ToArray();
}
public void AssertUnloaded(string worldPath) => CheckUnloaded(worldPath, Inspect());
public static void CheckUnloaded(string worldPath, IReadOnlyList<GameHostStatus> hosts)
{
foreach (var host in hosts)
{
if (host.State == "unknown") throw new OfflineWorldException("host_unknown", $"PID {host.Pid}: {host.Detail}");
if (host.WorldPath != null && OfflineWorldStore.SamePath(host.WorldPath, worldPath))
throw new OfflineWorldException("world_in_use", $"目标存档正由 PID {host.Pid} 使用;请使用实时 pb.deploy,或保存卸载后重试。");
}
}
private static bool LogStartMatches(string path, DateTimeOffset processStart)
{
string stamp = Path.GetFileNameWithoutExtension(path)["SpaceEngineers_".Length..];
if (!DateTime.TryParseExact(stamp, "yyyyMMdd_HHmmssfff", CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) return false;
double delta = (DateTime.SpecifyKind(local, DateTimeKind.Local).ToUniversalTime() - processStart.UtcDateTime).TotalSeconds;
return delta >= -2 && delta <= 30;
}
public static GameHostStatus ParseLog(TextReader reader, GameHostStatus host, string logPath)
{
bool executableMatches = false;
string state = "unknown";
string? world = null;
string? line;
while ((line = reader.ReadLine()) != null)
{
const string executableMarker = "Environment.CommandLine: ";
int command = line.IndexOf(executableMarker, StringComparison.Ordinal);
if (command >= 0 && host.ExecutablePath != null)
{
string value = line[(command + executableMarker.Length)..].Trim();
executableMatches = value.StartsWith('"')
? value.StartsWith('"' + host.ExecutablePath + '"', StringComparison.OrdinalIgnoreCase)
: value.Equals(host.ExecutablePath, StringComparison.OrdinalIgnoreCase) || value.StartsWith(host.ExecutablePath + " ", StringComparison.OrdinalIgnoreCase);
}
const string loading = "Loading session: ";
int load = line.IndexOf(loading, StringComparison.Ordinal);
if (load >= 0)
{
string path = line[(load + loading.Length)..].Trim();
state = Path.IsPathFullyQualified(path) ? "loaded_or_loading" : "unknown";
world = state == "unknown" ? null : Path.GetFullPath(path);
}
// Keep the last path protected until unloading is complete.
if (line.Contains("MySession::Unload END", StringComparison.Ordinal)) { state = "unloaded"; world = null; }
}
if (!executableMatches) return host;
return host with { State = state, WorldPath = world, EvidencePath = logPath,
Detail = state == "unknown" ? host.Detail : host.AgentAvailable ? "Agent 已连接;存档状态依据该进程日志。" : "游戏未加载 AgentBridge;可管理未加载的本地存档,实时操作需通过 XFE 加载器启动。" };
}
}