XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFE.SpaceEngineers.AgentBridge

【SpaceEngineer】AI调试插件

公开
关注 0 Fork 0 Star 0
返回提交历史

SpaceEngineersModDev/XFE.SpaceEngineers.AgentBridge

Add offline world deployment, backups and host diagnostics

18cdf7b
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

8 个文件 +663 -10
Modified README.md +2 -0
@@ -63,6 +63,8 @@ $cli = './src/XFE.SeAgent.Cli/bin/Release/net10.0/xfe-se.exe'
63 63
64 64 PB 部署备份保存在测试存档的 `Storage/XFE.AgentBridge/Backups` 中,含原源码、CustomData 和 Storage。编译失败通过结果和编译错误字段呈现;不要把“已写入”当作“编译成功”。
65 65
66 未加载插件或不需要启动游戏时,可使用管理端的 `host status`、`world list/inspect/read/backup/deploy/restore`。它能够识别游戏进程及当前存档、预演并批量更新未加载存档中的编程块、备份活动文件、失效旧二进制缓存,并按哈希撤销部署。默认预演,显式 `--apply true` 才写入;存档占用或状态不明时拒绝离线修改。配置、Storage、启用状态和其他设备保持不变,详细清单格式见 [客户端说明](src/XFE.SeAgent.Cli/README.md#无连接诊断与离线部署)。这条路径不依赖游戏端的测试副本授权列表,只操作命令中明确指定且未加载的本地存档。
67
66 68 `pb.inspect` 只遍历脚本程序集自身字段和有限集合,深度最多 3 层、总节点和字符串均有限制。不会执行任意 C#、调用用户指定的反射方法或遍历整个游戏对象图。
67 69
68 70 对接诊断可读取连接器的实际 `constraintPosition`、约束对端和距离、可连接/吸附状态及交易保护,避免把方块中心当作连接点。船体 `integrity` 区分已生效损伤、待结算伤害和未完成建造,并记录变形数量;每网格最多检查 20000 块,最多列出 32 个受损位置,超出时明确标记截断。普通 `grids.list` 不进行完整度汇总。用相同网格的飞行前后基线核对方块数量与完整度,具体字段见 [API 参考](docs/API.md)。
Modified src/XFE.SeAgent.Cli/CliApplication.cs +38 -6
@@ -5,10 +5,12 @@ using System.Text.Json;
5 5
6 6 namespace XFE.SeAgent.Cli;
7 7
8 public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPipeClient? client = null)
8 public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPipeClient? client = null, OfflineWorldStore? worlds = null, IWorldUseGuard? host = null)
9 9 {
10 10 private readonly EndpointDiscovery discovery = discovery ?? new();
11 11 private readonly AgentPipeClient client = client ?? new();
12 private readonly OfflineWorldStore worlds = worlds ?? new();
13 private readonly IWorldUseGuard host = host ?? new HostStatus(discovery);
12 14 private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
13 15
14 16 public async Task<int> RunAsync(string[] args, TextWriter output, TextWriter error, CancellationToken token = default)
@@ -20,11 +22,32 @@ public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPip
20 22 await WriteAsync(output, new { name = "xfe-se", protocolVersion = 1, commands = new[] {
21 23 "discover [--pid PID]", "call METHOD [--params JSON|@FILE] [--pipe NAME|--pid PID] [--timeout 30]",
22 24 "deploy --block ID --file SCRIPT.CS --expected-hash SHA256 [--pipe NAME|--pid PID]",
23 "watch --block ID,ID --seconds 30 --interval 1 --out telemetry.jsonl [--pipe NAME|--pid PID]" },
25 "watch --block ID,ID --seconds 30 --interval 1 --out telemetry.jsonl [--pipe NAME|--pid PID]",
26 "host status",
27 "world list [--root SAVE_ROOT]", "world inspect --path WORLD", "world read --path WORLD --block ID",
28 "world backup --path WORLD [--out NEW_DIRECTORY]",
29 "world deploy --manifest FILE [--apply true|false]",
30 "world restore --backup DEPLOYMENT_BACKUP" },
24 31 note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" });
25 32 return 0;
26 33 }
27 34 var arguments = Parse(args);
35 if (arguments.Command == "host")
36 { await WriteAsync(output, new { hosts = host.Inspect() }); return 0; }
37 if (arguments.Command == "world")
38 {
39 object result = arguments.Method switch
40 {
41 "list" => worlds.List(arguments.Value("--root")),
42 "inspect" => worlds.Inspect(arguments.Required("--path")),
43 "read" => worlds.ReadProgram(arguments.Required("--path"), arguments.Required("--block")),
44 "backup" => worlds.Backup(arguments.Required("--path"), arguments.Value("--out")),
45 "deploy" => worlds.Deploy(arguments.Required("--manifest"), Boolean(arguments.Value("--apply") ?? "false")),
46 "restore" => worlds.Restore(arguments.Required("--backup")),
47 _ => throw new CliUsageException("未知 world 操作。")
48 };
49 await WriteAsync(output, result); return 0;
50 }
28 51 if (arguments.Command == "discover")
29 52 {
30 53 var endpoints = discovery.Discover(arguments.Value("--endpoint-directory"));
@@ -66,7 +89,9 @@ public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPip
66 89 { await FailureAsync(output, error, "invalid_json", "输入 JSON 无效:" + ex.Message); return 2; }
67 90 catch (AgentClientException ex)
68 91 { await FailureAsync(output, error, ex.Code, ex.Message); return 3; }
69 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException)
92 catch (OfflineWorldException ex)
93 { await FailureAsync(output, error, ex.Code, ex.Message); return 5; }
94 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException or System.Xml.XmlException)
70 95 { await FailureAsync(output, error, "local_error", ex.Message); return 1; }
71 96 }
72 97
@@ -123,15 +148,22 @@ public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPip
123 148 private static int PositiveInt(string value, string name) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number) && number > 0 ? number : throw new CliUsageException(name + " 必须是正整数。");
124 149 private static string EntityId(string value) => long.TryParse(value.Trim(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long id) && id != 0 ? id.ToString(CultureInfo.InvariantCulture) : throw new CliUsageException("方块 ID 必须是非零 Int64 十进制整数。");
125 150 private static double Number(string text, string name, double min, double max) => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) && double.IsFinite(value) && value >= min && value <= max ? value : throw new CliUsageException($"{name}必须在 {min} 至 {max} 秒之间。");
151 private static bool Boolean(string value) => bool.TryParse(value, out bool result) ? result : throw new CliUsageException("--apply 必须是 true 或 false。");
126 152
127 153 private static Arguments Parse(string[] args)
128 154 {
129 155 string command = args[0];
130 if (command is not ("discover" or "call" or "deploy" or "watch")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
156 if (command is not ("discover" or "call" or "deploy" or "watch" or "host" or "world")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
131 157 int position = 1; string? method = null;
132 if (command == "call") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException("call 需要方法名。"); method = args[position++]; }
158 if (command is "call" or "world" or "host") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException(command + " 需要方法名。"); method = args[position++]; }
133 159 string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] };
134 var allowed = specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" }).ToHashSet(StringComparer.Ordinal);
160 string[] local = command == "host" && method == "status" ? [] : command == "world" ? method switch
161 {
162 "list" => ["--root"], "inspect" => ["--path"], "read" => ["--path", "--block"],
163 "backup" => ["--path", "--out"], "deploy" => ["--manifest", "--apply"], "restore" => ["--backup"],
164 _ => throw new CliUsageException("未知 world 操作。")
165 } : command == "host" ? throw new CliUsageException("未知 host 操作。") : [];
166 var allowed = (command is "host" or "world" ? local : specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" })).ToHashSet(StringComparer.Ordinal);
135 167 var options = new Dictionary<string, string>(StringComparer.Ordinal);
136 168 while (position < args.Length)
137 169 {
Added src/XFE.SeAgent.Cli/HostStatus.cs +116 -0
@@ -0,0 +1,116 @@
1 using System.Diagnostics;
2 using System.Globalization;
3
4 namespace XFE.SeAgent.Cli;
5
6 public sealed record GameHostStatus(int Pid, string? ExecutablePath, DateTimeOffset? StartTimeUtc,
7 bool AgentAvailable, string State, string? WorldPath, string? EvidencePath, string Detail);
8
9 public interface IWorldUseGuard
10 {
11 IReadOnlyList<GameHostStatus> Inspect();
12 void AssertUnloaded(string worldPath);
13 }
14
15 public sealed class HostStatus(EndpointDiscovery? discovery = null) : IWorldUseGuard
16 {
17 private readonly EndpointDiscovery discovery = discovery ?? new();
18 public static string GameDataDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SpaceEngineers");
19
20 public IReadOnlyList<GameHostStatus> Inspect()
21 {
22 var endpoints = discovery.Discover();
23 var result = new List<GameHostStatus>();
24 foreach (var process in Process.GetProcesses())
25 {
26 using (process)
27 {
28 string name;
29 try { name = process.ProcessName; }
30 catch (InvalidOperationException) { continue; }
31 if (!name.Contains("SpaceEngineers", StringComparison.OrdinalIgnoreCase)) continue;
32 var identity = new LocalProcessInspector().Inspect(process.Id);
33 bool agent = endpoints.Any(item => item.Pid == process.Id);
34 var state = new GameHostStatus(process.Id, identity?.ExecutablePath, identity?.StartTimeUtc,
35 agent, "unknown", null, null, "无法确定游戏正在使用的存档;拒绝离线写入。请使用实时接口,或保存退出游戏。");
36 if (identity != null && Directory.Exists(GameDataDirectory))
37 {
38 // Correlate by process start AND executable in the log header, never just the newest log.
39 var logs = Directory.EnumerateFiles(GameDataDirectory, "SpaceEngineers_*.log")
40 .Where(path => LogStartMatches(path, identity.StartTimeUtc)).ToArray();
41 if (logs.Length == 1)
42 {
43 try
44 {
45 using var input = new FileStream(logs[0], FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
46 if (input.Length <= 256L * 1024 * 1024)
47 {
48 using var reader = new StreamReader(input);
49 state = ParseLog(reader, state, logs[0]);
50 }
51 }
52 catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
53 }
54 }
55 result.Add(state);
56 }
57 }
58 // Two instances cannot both be identified by the same log.
59 foreach (var group in result.Where(x => x.EvidencePath != null).GroupBy(x => x.EvidencePath, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1))
60 foreach (var host in group.ToArray()) result[result.IndexOf(host)] = host with { State = "unknown", WorldPath = null, Detail = "多个进程匹配同一日志,无法确认存档使用状态。" };
61 return result.OrderBy(x => x.Pid).ToArray();
62 }
63
64 public void AssertUnloaded(string worldPath) => CheckUnloaded(worldPath, Inspect());
65
66 public static void CheckUnloaded(string worldPath, IReadOnlyList<GameHostStatus> hosts)
67 {
68 foreach (var host in hosts)
69 {
70 if (host.State == "unknown") throw new OfflineWorldException("host_unknown", $"PID {host.Pid}: {host.Detail}");
71 if (host.WorldPath != null && OfflineWorldStore.SamePath(host.WorldPath, worldPath))
72 throw new OfflineWorldException("world_in_use", $"目标存档正由 PID {host.Pid} 使用;请使用实时 pb.deploy,或保存卸载后重试。");
73 }
74 }
75
76 private static bool LogStartMatches(string path, DateTimeOffset processStart)
77 {
78 string stamp = Path.GetFileNameWithoutExtension(path)["SpaceEngineers_".Length..];
79 if (!DateTime.TryParseExact(stamp, "yyyyMMdd_HHmmssfff", CultureInfo.InvariantCulture, DateTimeStyles.None, out var local)) return false;
80 double delta = (DateTime.SpecifyKind(local, DateTimeKind.Local).ToUniversalTime() - processStart.UtcDateTime).TotalSeconds;
81 return delta >= -2 && delta <= 30;
82 }
83
84 public static GameHostStatus ParseLog(TextReader reader, GameHostStatus host, string logPath)
85 {
86 bool executableMatches = false;
87 string state = "unknown";
88 string? world = null;
89 string? line;
90 while ((line = reader.ReadLine()) != null)
91 {
92 const string executableMarker = "Environment.CommandLine: ";
93 int command = line.IndexOf(executableMarker, StringComparison.Ordinal);
94 if (command >= 0 && host.ExecutablePath != null)
95 {
96 string value = line[(command + executableMarker.Length)..].Trim();
97 executableMatches = value.StartsWith('"')
98 ? value.StartsWith('"' + host.ExecutablePath + '"', StringComparison.OrdinalIgnoreCase)
99 : value.Equals(host.ExecutablePath, StringComparison.OrdinalIgnoreCase) || value.StartsWith(host.ExecutablePath + " ", StringComparison.OrdinalIgnoreCase);
100 }
101 const string loading = "Loading session: ";
102 int load = line.IndexOf(loading, StringComparison.Ordinal);
103 if (load >= 0)
104 {
105 string path = line[(load + loading.Length)..].Trim();
106 state = Path.IsPathFullyQualified(path) ? "loaded_or_loading" : "unknown";
107 world = state == "unknown" ? null : Path.GetFullPath(path);
108 }
109 // Keep the last path protected until unloading is complete.
110 if (line.Contains("MySession::Unload END", StringComparison.Ordinal)) { state = "unloaded"; world = null; }
111 }
112 if (!executableMatches) return host;
113 return host with { State = state, WorldPath = world, EvidencePath = logPath,
114 Detail = state == "unknown" ? host.Detail : host.AgentAvailable ? "Agent 已连接;存档状态依据该进程日志。" : "游戏未加载 AgentBridge;可管理未加载的本地存档,实时操作需通过 XFE 加载器启动。" };
115 }
116 }
Added src/XFE.SeAgent.Cli/OfflineWorldStore.cs +329 -0
@@ -0,0 +1,329 @@
1 using System.Globalization;
2 using System.Security.Cryptography;
3 using System.Text;
4 using System.Text.Json;
5 using System.Text.Json.Serialization;
6 using System.Xml;
7 using System.Xml.Linq;
8
9 namespace XFE.SeAgent.Cli;
10
11 public sealed class OfflineWorldException(string code, string message) : Exception(message)
12 { public string Code { get; } = code; }
13
14 public sealed record WorldDeployment(int Version, string WorldPath, string ExpectedSectorSha256, ScriptUpdate[] Scripts);
15 public sealed record ScriptUpdate(string EntityId, string ExpectedSha256, string SourcePath, string SourceSha256);
16 public sealed record SavedProgram(string EntityId, string Name, string GridId, string GridName, string Sha256, int Characters, string? Enabled, string StorageSha256);
17 public sealed record FileDigest(string Path, long Bytes, string Sha256);
18 public sealed record ScriptChange(string EntityId, string Name, string BeforeSha256, string AfterSha256, int Characters);
19 public sealed record WorldSnapshot(int Version, string WorldPath, DateTimeOffset CreatedUtc, FileDigest[] Files, string? DeployedSectorSha256, ScriptChange[] Changes);
20 public sealed record DeploymentResult(bool Applied, string WorldPath, string BeforeSectorSha256, string AfterSectorSha256,
21 ScriptChange[] Changes, string[] InvalidatedCaches, string? BackupPath, string Compilation);
22
23 /// <summary>Generic, local save management. Never runs game code or edits configuration/Storage.</summary>
24 public sealed class OfflineWorldStore(IWorldUseGuard? guard = null, string? dataDirectory = null)
25 {
26 public const string SectorName = "SANDBOX_0_0_0_.sbs";
27 private readonly IWorldUseGuard guard = guard ?? new HostStatus();
28 private readonly string dataDirectory = dataDirectory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFE", "SpaceEngineersAgent");
29 private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
30 private static readonly XNamespace Xsi = "http://www.w3.org/2001/XMLSchema-instance";
31
32 public object List(string? root = null)
33 {
34 root = Path.GetFullPath(root ?? Path.Combine(HostStatus.GameDataDirectory, "Saves"));
35 var worlds = new List<object>();
36 if (Directory.Exists(root))
37 {
38 var candidates = new List<string> { root };
39 foreach (string directory in SafeDirectories(root))
40 { candidates.Add(directory); candidates.AddRange(SafeDirectories(directory)); }
41 foreach (string path in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
42 if (File.Exists(Path.Combine(path, "Sandbox.sbc")))
43 worlds.Add(new { path, name = Path.GetFileName(path), modifiedUtc = File.GetLastWriteTimeUtc(Path.Combine(path, "Sandbox.sbc")), hasXmlSector = File.Exists(Path.Combine(path, SectorName)) });
44 }
45 return new { root, worlds };
46 }
47
48 public object Inspect(string worldPath)
49 {
50 string world = WorldPath(worldPath);
51 var sector = ReadSector(world);
52 return new { worldPath = world, sectorSha256 = Sha(sector.Bytes), programs = Programs(sector.Document).Select(Describe).ToArray(),
53 caches = CacheFiles(world).Select(path => new { name = Path.GetFileName(path), bytes = new FileInfo(path).Length }).ToArray(),
54 hosts = guard.Inspect(), note = "磁盘内容;已加载世界的内存状态应通过实时 pb.read 检查。" };
55 }
56
57 public object ReadProgram(string worldPath, string entityId)
58 {
59 string id = Id(entityId);
60 var block = Programs(ReadSector(WorldPath(worldPath)).Document).SingleOrDefault(x => Value(x.Block, "EntityId") == id)
61 ?? throw new OfflineWorldException("block_missing", "未找到编程块:" + id);
62 return new { program = Describe(block), source = Value(block.Block, "Program"), storage = Value(block.Block, "Storage") };
63 }
64
65 public object Backup(string worldPath, string? destination = null)
66 {
67 string world = WorldPath(worldPath);
68 using var operation = Lock(world);
69 guard.AssertUnloaded(world);
70 string backup = CreateSnapshot(world, destination, null, []);
71 return new { worldPath = world, backupPath = backup, snapshot = ReadJson<WorldSnapshot>(Path.Combine(backup, "backup.json")), note = "完整离线快照(不含游戏历史 Backup 目录);原存档未修改。" };
72 }
73
74 public DeploymentResult Deploy(string manifestPath, bool apply = false)
75 {
76 manifestPath = Path.GetFullPath(manifestPath);
77 var manifest = ReadJson<WorldDeployment>(manifestPath);
78 if (manifest.Version != 1 || manifest.Scripts == null || manifest.Scripts.Length is < 1 or > 1024)
79 throw new OfflineWorldException("invalid_manifest", "version 必须为 1,scripts 必须包含 1 至 1024 个条目。");
80 string world = WorldPath(manifest.WorldPath);
81 using var operation = Lock(world);
82 guard.AssertUnloaded(world);
83 var sector = ReadSector(world);
84 string before = Sha(sector.Bytes);
85 Match(before, manifest.ExpectedSectorSha256, "存档 sector 已变化,请重新 inspect。");
86 var edited = new XDocument(sector.Document);
87 var programs = Programs(edited).ToDictionary(x => Id(Value(x.Block, "EntityId")), StringComparer.Ordinal);
88 var changes = new List<ScriptChange>();
89 var seen = new HashSet<string>(StringComparer.Ordinal);
90 foreach (var update in manifest.Scripts)
91 {
92 if (update == null) throw new OfflineWorldException("invalid_manifest", "scripts 不得包含 null。");
93 string id = Id(update.EntityId);
94 if (!seen.Add(id)) throw new OfflineWorldException("invalid_manifest", "编程块 ID 重复:" + id);
95 if (!programs.TryGetValue(id, out var program)) throw new OfflineWorldException("block_missing", "未找到编程块:" + id);
96 string oldHash = Sha(Value(program.Block, "Program"));
97 Match(oldHash, update.ExpectedSha256, "编程块旧源码不匹配:" + id);
98 if (string.IsNullOrWhiteSpace(update.SourcePath)) throw new OfflineWorldException("invalid_manifest", "sourcePath 不能为空。");
99 string sourcePath = Path.GetFullPath(update.SourcePath, Path.GetDirectoryName(manifestPath)!);
100 if (new FileInfo(sourcePath).Length > 1024 * 1024) throw new OfflineWorldException("source_too_large", "源码文件超过 1 MiB。");
101 string source = File.ReadAllText(sourcePath, new UTF8Encoding(false, true));
102 if (source.Length > 100000) throw new OfflineWorldException("source_too_large", "编程块源码超过游戏的 100000 字符上限。");
103 string newHash = Sha(source);
104 Match(newHash, update.SourceSha256, "部署文件内容已变化:" + sourcePath);
105 var element = Child(program.Block, "Program");
106 if (element == null) { element = new XElement(program.Block.Name.Namespace + "Program"); program.Block.Add(element); }
107 element.Value = source;
108 changes.Add(new(id, Value(program.Block, "CustomName"), oldHash, newHash, source.Length));
109 }
110 byte[] replacement = Serialize(edited, sector.Bytes);
111 VerifyOnlyProgramsChanged(sector.Document, Parse(replacement), seen);
112 string after = Sha(replacement);
113 string[] caches = CacheFiles(world).Select(Path.GetFileName).Cast<string>().ToArray();
114 if (!apply) return new(false, world, before, after, changes.ToArray(), caches, null, "未编译;离线写入后游戏下次加载时编译。");
115 string backup = CreateSnapshot(world, null, after, changes.ToArray());
116 var snapshot = ReadJson<WorldSnapshot>(Path.Combine(backup, "backup.json"));
117 Match(snapshot.Files.Single(f => f.Path == SectorName).Sha256, before, "备份前存档已变化。");
118 string temporary = Path.Combine(world, ".xfe-se-" + Guid.NewGuid().ToString("N") + ".tmp");
119 bool mutationStarted = false;
120 try
121 {
122 WriteDurable(temporary, replacement);
123 guard.AssertUnloaded(world);
124 VerifyFiles(world, snapshot.Files, temporary);
125 // Removing the cache first makes even an interrupted deployment fall back to valid XML.
126 mutationStarted = true;
127 foreach (string name in caches) File.Delete(Under(world, name));
128 File.Replace(temporary, Path.Combine(world, SectorName), null);
129 Match(FileSha(Path.Combine(world, SectorName)), after, "写入后校验失败。");
130 VerifyFiles(world, ExpectedDeployedFiles(snapshot));
131 }
132 catch (Exception failure)
133 {
134 if (!mutationStarted) throw;
135 // Recovery is conservative: never overwrite an unexpected edit or an active save.
136 try { RestoreCore(backup, snapshot, allowPartial: true, ignoredPath: temporary); }
137 catch (Exception recovery) { throw new OfflineWorldException("recovery_required", $"部署未完成:{failure.Message};自动恢复失败:{recovery.Message}。备份:{backup}"); }
138 throw new OfflineWorldException("deployment_rolled_back", $"部署失败,已恢复原文件:{failure.Message}。备份:{backup}");
139 }
140 finally { if (File.Exists(temporary)) File.Delete(temporary); }
141 return new(true, world, before, after, changes.ToArray(), caches, backup, "未执行游戏内编译;下次加载此存档时生效。");
142 }
143
144 public object Restore(string backupPath)
145 {
146 string backup = Path.GetFullPath(backupPath);
147 var snapshot = ReadJson<WorldSnapshot>(Path.Combine(backup, "backup.json"));
148 string world = WorldPath(snapshot.WorldPath);
149 using var operation = Lock(world);
150 RestoreCore(backup, snapshot, false);
151 return new { restored = true, worldPath = world, backupPath = backup, note = "源码和二进制缓存已恢复;其他存档文件保持一致。" };
152 }
153
154 private void RestoreCore(string backup, WorldSnapshot snapshot, bool allowPartial, string? ignoredPath = null)
155 {
156 if (snapshot.Version != 1 || snapshot.DeployedSectorSha256 == null || snapshot.Files == null || snapshot.Files.Length == 0)
157 throw new OfflineWorldException("invalid_backup", "restore 需要由 world deploy 创建的事务备份;普通快照可复制为另一个存档。");
158 string world = WorldPath(snapshot.WorldPath);
159 guard.AssertUnloaded(world);
160 foreach (var file in snapshot.Files) Match(FileSha(Under(Path.Combine(backup, "files"), file.Path)), file.Sha256, "备份文件已变化:" + file.Path);
161 var expected = ExpectedDeployedFiles(snapshot).ToList();
162 if (allowPartial)
163 {
164 string current = FileSha(Path.Combine(world, SectorName));
165 var original = snapshot.Files.Single(x => x.Path == SectorName);
166 if (current == original.Sha256) expected[expected.FindIndex(x => x.Path == SectorName)] = original;
167 foreach (var file in snapshot.Files.Where(x => IsCache(x.Path)))
168 if (File.Exists(Under(world, file.Path))) expected.Add(file);
169 }
170 VerifyFiles(world, expected.ToArray(), ignoredPath);
171 guard.AssertUnloaded(world);
172 foreach (var file in snapshot.Files.Where(x => x.Path == SectorName || IsCache(x.Path)))
173 {
174 string target = Under(world, file.Path);
175 if (File.Exists(target) && FileSha(target) == file.Sha256) continue;
176 string temp = target + ".xfe-restore-" + Guid.NewGuid().ToString("N");
177 try
178 {
179 File.Copy(Under(Path.Combine(backup, "files"), file.Path), temp, false);
180 if (File.Exists(target)) File.Replace(temp, target, null); else File.Move(temp, target);
181 }
182 finally { if (File.Exists(temp)) File.Delete(temp); }
183 }
184 VerifyFiles(world, snapshot.Files, ignoredPath);
185 }
186
187 private string CreateSnapshot(string world, string? destination, string? deployedHash, ScriptChange[] changes)
188 {
189 guard.AssertUnloaded(world);
190 string backup = Path.GetFullPath(destination ?? Path.Combine(dataDirectory, "backups", DateTime.UtcNow.ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture) + "-" + Guid.NewGuid().ToString("N")));
191 if (SamePath(backup, world) || backup.StartsWith(world + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || Directory.Exists(backup) || File.Exists(backup))
192 throw new OfflineWorldException("invalid_backup_path", "备份目录必须是存档以外的新目录。");
193 EnsureNoLinks(backup);
194 var files = ActiveFiles(world);
195 Directory.CreateDirectory(Path.Combine(backup, "files"));
196 var digests = new List<FileDigest>();
197 foreach (string relative in files)
198 {
199 string source = Under(world, relative), target = Under(Path.Combine(backup, "files"), relative);
200 Directory.CreateDirectory(Path.GetDirectoryName(target)!);
201 using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read))
202 using (var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None))
203 { input.CopyTo(output); output.Flush(true); }
204 digests.Add(new(relative, new FileInfo(target).Length, FileSha(target)));
205 }
206 guard.AssertUnloaded(world);
207 VerifyFiles(world, digests.ToArray());
208 var snapshot = new WorldSnapshot(1, world, DateTimeOffset.UtcNow, digests.ToArray(), deployedHash, changes);
209 WriteDurable(Path.Combine(backup, "backup.json"), Encoding.UTF8.GetBytes(JsonSerializer.Serialize(snapshot, Json)));
210 return backup;
211 }
212
213 private static FileDigest[] ExpectedDeployedFiles(WorldSnapshot snapshot) => snapshot.Files.Where(x => !IsCache(x.Path)).Select(x =>
214 x.Path == SectorName ? x with { Sha256 = snapshot.DeployedSectorSha256!, Bytes = -1 } : x).ToArray();
215
216 private static void VerifyFiles(string world, FileDigest[] files, string? ignoredPath = null)
217 {
218 string[] actual = ActiveFiles(world).Where(p => ignoredPath == null || !SamePath(Under(world, p), ignoredPath)).ToArray();
219 if (!actual.Order(StringComparer.OrdinalIgnoreCase).SequenceEqual(files.Select(x => x.Path).Order(StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase))
220 throw new OfflineWorldException("concurrent_change", "存档文件列表已变化,拒绝覆盖。");
221 foreach (var file in files) Match(FileSha(Under(world, file.Path)), file.Sha256, "存档文件已变化:" + file.Path);
222 }
223
224 private FileStream Lock(string world)
225 {
226 string directory = Path.Combine(dataDirectory, "offline-locks"); Directory.CreateDirectory(directory);
227 return new FileStream(Path.Combine(directory, Sha(world.ToUpperInvariant()) + ".lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
228 }
229 public static bool SamePath(string a, string b) => string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(a)), Path.TrimEndingDirectorySeparator(Path.GetFullPath(b)), StringComparison.OrdinalIgnoreCase);
230 public static string Sha(string text) => Sha(Encoding.UTF8.GetBytes(text));
231 public static string Sha(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
232 public static string FileSha(string path) { using var input = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); return Convert.ToHexStringLower(SHA256.HashData(input)); }
233 private static void Match(string actual, string? expected, string detail)
234 {
235 if (expected == null || expected.Length != 64 || !expected.All(Uri.IsHexDigit)) throw new OfflineWorldException("invalid_hash", "需要完整 SHA-256:" + detail);
236 if (!actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) throw new OfflineWorldException("hash_mismatch", detail);
237 }
238 private static string Id(string? value) => long.TryParse(value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long id) && id != 0 ? id.ToString(CultureInfo.InvariantCulture) : throw new OfflineWorldException("invalid_id", "需要非零 Int64 字符串 ID。");
239 private static T ReadJson<T>(string path)
240 {
241 if (new FileInfo(path).Length > 8 * 1024 * 1024) throw new OfflineWorldException("invalid_manifest", "JSON 文件超过 8 MiB。");
242 return JsonSerializer.Deserialize<T>(File.ReadAllText(path), Json) ?? throw new OfflineWorldException("invalid_manifest", "JSON 不能为空。");
243 }
244 private static string WorldPath(string path)
245 {
246 if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) throw new OfflineWorldException("invalid_world", "存档路径必须是绝对路径。");
247 path = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); EnsureNoLinks(path);
248 if (!File.Exists(Path.Combine(path, "Sandbox.sbc"))) throw new OfflineWorldException("invalid_world", "目录中没有 Sandbox.sbc。");
249 return path;
250 }
251 private static string Under(string root, string relative)
252 {
253 string path = Path.GetFullPath(Path.Combine(root, relative));
254 if (Path.IsPathRooted(relative) || !path.StartsWith(Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
255 throw new OfflineWorldException("invalid_path", "文件路径超出目标目录。");
256 EnsureNoLinks(path); return path;
257 }
258 private static void EnsureNoLinks(string path)
259 {
260 for (string? current = Path.GetFullPath(path); current != null; current = Path.GetDirectoryName(current))
261 if ((Directory.Exists(current) || File.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
262 throw new OfflineWorldException("linked_path", "离线存档操作不跟随链接或目录联接:" + current);
263 }
264 private static IEnumerable<string> SafeDirectories(string root) => Directory.EnumerateDirectories(root).Where(p => (File.GetAttributes(p) & FileAttributes.ReparsePoint) == 0).Take(10000);
265 private static string[] ActiveFiles(string world)
266 {
267 var files = new List<string>();
268 void Walk(string directory)
269 {
270 foreach (string path in Directory.EnumerateFileSystemEntries(directory))
271 {
272 if (SamePath(directory, world) && Path.GetFileName(path).Equals("Backup", StringComparison.OrdinalIgnoreCase)) continue;
273 EnsureNoLinks(path);
274 if (Directory.Exists(path)) Walk(path); else files.Add(Path.GetRelativePath(world, path));
275 if (files.Count > 200000) throw new OfflineWorldException("world_too_large", "存档文件超过 200000 个。");
276 }
277 }
278 Walk(world); return files.Order(StringComparer.OrdinalIgnoreCase).ToArray();
279 }
280 private static bool IsCache(string relative) => Path.GetFileName(relative).Equals(relative, StringComparison.Ordinal) && relative.StartsWith(SectorName + "B", StringComparison.OrdinalIgnoreCase)
281 && relative.Length > SectorName.Length + 1 && relative[(SectorName.Length + 1)..].All(char.IsAsciiDigit);
282 private static string[] CacheFiles(string world) => Directory.EnumerateFiles(world).Where(p => IsCache(Path.GetFileName(p))).ToArray();
283 private static (byte[] Bytes, XDocument Document) ReadSector(string world)
284 {
285 string path = Under(world, SectorName);
286 if (!File.Exists(path)) throw new OfflineWorldException("xml_sector_missing", "缺少 XML sector;请先在游戏中生成 XML 存档,不能直接修改二进制缓存。");
287 if (new FileInfo(path).Length > 512L * 1024 * 1024) throw new OfflineWorldException("world_too_large", "XML sector 超过 512 MiB。");
288 byte[] bytes = File.ReadAllBytes(path); return (bytes, Parse(bytes));
289 }
290 private static XDocument Parse(byte[] bytes)
291 {
292 using var stream = new MemoryStream(bytes);
293 using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 512L * 1024 * 1024 });
294 return XDocument.Load(reader, LoadOptions.PreserveWhitespace);
295 }
296 private sealed record ProgramNode(XElement Block, XElement Grid);
297 private static XElement? Child(XElement element, string name) => element.Elements().FirstOrDefault(x => x.Name.LocalName == name);
298 private static string Value(XElement element, string name) => Child(element, name)?.Value ?? "";
299 private static IEnumerable<ProgramNode> Programs(XDocument document)
300 {
301 var objects = document.Root == null ? null : Child(document.Root, "SectorObjects");
302 if (objects == null) throw new OfflineWorldException("invalid_sector", "未找到 SectorObjects。");
303 foreach (var grid in objects.Elements())
304 foreach (var block in Child(grid, "CubeBlocks")?.Elements() ?? [])
305 if (((string?)block.Attribute(Xsi + "type") ?? block.Name.LocalName) is "MyObjectBuilder_MyProgrammableBlock" or "MyObjectBuilder_ProgrammableBlock")
306 yield return new(block, grid);
307 }
308 private static SavedProgram Describe(ProgramNode node) => new(Value(node.Block, "EntityId"), Value(node.Block, "CustomName"), Value(node.Grid, "EntityId"), Value(node.Grid, "DisplayName"),
309 Sha(Value(node.Block, "Program")), Value(node.Block, "Program").Length, Child(node.Block, "Enabled")?.Value, Sha(Value(node.Block, "Storage")));
310 private static byte[] Serialize(XDocument document, byte[] original)
311 {
312 using var output = new MemoryStream();
313 using (var writer = XmlWriter.Create(output, new XmlWriterSettings { Encoding = new UTF8Encoding(original.AsSpan().StartsWith(new byte[] { 239, 187, 191 })), Indent = false, NewLineHandling = NewLineHandling.Entitize, OmitXmlDeclaration = document.Declaration == null })) document.Save(writer);
314 return output.ToArray();
315 }
316 private static void VerifyOnlyProgramsChanged(XDocument before, XDocument after, HashSet<string> ids)
317 {
318 var old = Programs(before).ToDictionary(x => Value(x.Block, "EntityId"));
319 foreach (var program in Programs(after).Where(x => ids.Contains(Value(x.Block, "EntityId"))))
320 {
321 var prior = Child(old[Value(program.Block, "EntityId")].Block, "Program");
322 var element = Child(program.Block, "Program")!;
323 if (prior == null) element.Remove(); else element.ReplaceWith(new XElement(prior));
324 }
325 if (!XNode.DeepEquals(before, after)) throw new OfflineWorldException("preservation_failed", "XML 往返校验发现源码以外的内容变化,拒绝写入。");
326 }
327 private static void WriteDurable(string path, byte[] bytes)
328 { using var file = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); file.Write(bytes); file.Flush(true); }
329 }
Modified src/XFE.SeAgent.Cli/README.md +45 -2
@@ -1,6 +1,6 @@
1 1 # xfe-se 命令行
2 2
3 用于从本机调用游戏中的 XFE Agent。命令行不直接修改存档,也不加载游戏程序集;所有游戏操作由 Agent 在游戏更新线程执行。
3 用于调用游戏中的 XFE Agent,以及管理本机未加载的存档。实时操作通过 Agent 在游戏更新线程执行;`host` / `world` 命令无需游戏端连接,也不加载游戏程序集。
4 4
5 5 ```powershell
6 6 dotnet build src/XFE.SeAgent.Cli -c Release
@@ -29,4 +29,47 @@ $seAgent = "src/XFE.SeAgent.Cli/bin/Release/net10.0/xfe-se.exe"
29 29
30 30 `watch` 调用 `telemetry.snapshot`,将 `entityIds` 作为十进制字符串数组传递。输出文件每行包含 `capturedUtc` 和原始 `response`,每个采样后刷新到磁盘;命令结束时 stdout 返回采样数量、时长与文件路径。指定的输出文件会被重新创建。
31 31
32 所有命令的 stdout 都是 JSON;诊断信息仅写入 stderr。退出码:0 成功,1 本地文件/权限错误,2 参数错误,3 管道或协议错误,4 Agent 返回业务错误,130 用户取消。请求限制为 1 MiB,响应限制为 4 MiB。`Ctrl+C` 取消等待;游戏线程中已经开始的操作无法通过关闭客户端回滚。
32 ## 无连接诊断与离线部署
33
34 ```powershell
35 & $seAgent host status
36 & $seAgent world list
37 & $seAgent world inspect --path 'C:\Saves\MyWorld'
38 & $seAgent world read --path 'C:\Saves\MyWorld' --block 1234567890123456789
39 & $seAgent world backup --path 'C:\Saves\MyWorld'
40 & $seAgent world deploy --manifest ./deployment.json
41 & $seAgent world deploy --manifest ./deployment.json --apply true
42 & $seAgent world restore --backup 'C:\path\to\deployment-backup'
43 ```
44
45 `host status` 同时列出游戏进程、有效 Agent 端点及日志推断的存档状态。日志必须同时匹配进程启动时间和可执行文件;状态不明或目标正在加载/运行时拒绝离线写入。若游戏未加载插件,诊断会提示通过 XFE 加载器启动;不会假装已经连接或自动退出当前游戏。检查结果来自日志,不能取代已连接时的 `world.status`。
46
47 `world list` 默认枚举当前用户的 `SpaceEngineers/Saves`,可用 `--root` 指定存档根目录。`inspect` 返回 sector 文件 SHA-256、实际网格上所有编程块的 ID、名称、源码哈希、字符数、Storage 哈希和启用状态;不包含投影蓝图中的编程块。`read` 返回指定编程块的完整源码与 Storage。读取已加载世界时这些是磁盘内容。
48
49 部署清单(路径为示例,摘要必须来自实际文件):
50
51 ```json
52 {
53 "version": 1,
54 "worldPath": "C:\\Saves\\MyWorld",
55 "expectedSectorSha256": "<inspect 返回的 sectorSha256>",
56 "scripts": [
57 {
58 "entityId": "1234567890123456789",
59 "expectedSha256": "<inspect 返回的旧源码 sha256>",
60 "sourcePath": "./script.cs",
61 "sourceSha256": "<新源码 UTF-8 文本的 SHA-256>"
62 }
63 ]
64 }
65 ```
66
67 `sourcePath` 相对于清单目录解析;ID 必须为十进制字符串。源码哈希使用 UTF-8 文本(去除文件 BOM,保留换行)。每份源码不超过 100000 个 UTF-16 字符、文件不超过 1 MiB。每份清单最多 1024 个不同编程块,不允许未知字段、缺失哈希或重复 ID。
68
69 `world deploy` 默认只预演,返回每个方块的新旧摘要及需要失效的二进制缓存。`--apply true` 才写入:重新检查存档使用状态和文件哈希,备份所有活动文件(不含游戏历史 `Backup` 目录),只替换指定 `Program`,核对整个 XML 的其他内容等价后原子替换 sector,并移除已备份的 `.sbsB数字` 缓存。Storage、CustomData、启用状态、其他脚本、世界设置、硬件和体素不改变。游戏下次加载时重新编译;离线部署不会声称游戏内编译已成功。
70
71 备份默认位于 `%LOCALAPPDATA%/XFE/SpaceEngineersAgent/backups/<时间与随机编号>`,含 `backup.json` 哈希清单和 `files/` 原文件。`world backup --out NEW_DIRECTORY` 可单独保存完整离线快照。路径不能指向目标存档内部或现有目录;不跟随符号链接和目录联接。
72
73 `world restore` 撤销一次部署并恢复旧二进制缓存;必须使用部署事务的备份,且全部活动文件仍与部署完成时一致。加载并保存产生变化后会拒绝自动回滚,避免抹去游戏进度。普通 `world backup` 快照用于人工恢复或复制成另一个存档,不接受事务回滚命令。程序崩溃后的部分事务保留原 XML/备份,需先检查实际文件;不会绕过哈希冲突强制覆盖。
74
75 所有命令的 stdout 都是 JSON;诊断信息仅写入 stderr。退出码:0 成功,1 本地文件/权限错误,2 参数错误,3 管道或协议错误,4 Agent 返回业务错误,5 离线存档校验/冲突错误,130 用户取消。实时请求限制为 1 MiB,响应限制为 4 MiB。`Ctrl+C` 取消实时等待;游戏线程中已经开始的操作无法通过关闭客户端回滚。离线写入事务会完成提交或恢复后才返回,操作期间请勿加载目标存档。
Added tests/XFE.SeAgent.Tests/OfflineWorldTests.cs +128 -0
@@ -0,0 +1,128 @@
1 #nullable enable
2 using System.Text;
3 using System.Text.Json;
4 using System.Xml;
5 using System.Xml.Linq;
6 using XFE.SeAgent.Cli;
7
8 namespace XFE.SeAgent.Tests;
9
10 internal static partial class Program
11 {
12 private sealed class WorldGuard : IWorldUseGuard
13 {
14 public IReadOnlyList<GameHostStatus> Hosts = [];
15 public Action<int>? OnCheck;
16 public int Checks;
17 public IReadOnlyList<GameHostStatus> Inspect() => Hosts;
18 public void AssertUnloaded(string path) { OnCheck?.Invoke(++Checks); HostStatus.CheckUnloaded(path, Hosts); }
19 }
20
21 private static async Task TestOfflineWorldsAsync()
22 {
23 string root = Path.Combine(Fixtures, "offline"), world = Path.Combine(root, "存档"), data = Path.Combine(root, "bridge");
24 Directory.CreateDirectory(world);
25 File.WriteAllText(Path.Combine(world, "Sandbox.sbc"), "<checkpoint><online>PUBLIC</online></checkpoint>");
26 File.WriteAllText(Path.Combine(world, "Sandbox_config.sbc"), "<config />");
27 File.WriteAllBytes(Path.Combine(world, "planet.vx2"), [0, 255, 12, 34, 78]);
28 File.WriteAllText(Path.Combine(world, OfflineWorldStore.SectorName + "B5"), "binary-cache");
29 File.WriteAllText(Path.Combine(world, OfflineWorldStore.SectorName + "Banana"), "not-a-cache");
30 Directory.CreateDirectory(Path.Combine(world, "Backup", "previous"));
31 File.WriteAllText(Path.Combine(world, "Backup", "previous", "history"), "old-game-backup");
32 const string source1 = "// 中文 & <>\r\npublic void Main() {}", source2 = "// 第二个\npublic void Main() {}";
33 XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
34 XElement Block(string id, string name, string source) => new("MyObjectBuilder_CubeBlock", new XAttribute(xsi + "type", "MyObjectBuilder_MyProgrammableBlock"),
35 new XElement("EntityId", id), new XElement("CustomName", name), new XElement("Enabled", "false"),
36 new XElement("Program", source), new XElement("Storage", "持久\r\n数据"), new XElement("CustomData", "[setting]\nvalue=42"));
37 var document = new XDocument(new XDeclaration("1.0", "utf-8", null), new XElement("MyObjectBuilder_Sector", new XAttribute(XNamespace.Xmlns + "xsi", xsi),
38 new XElement("SectorObjects", new XElement("MyObjectBuilder_EntityBase", new XElement("EntityId", "99"), new XElement("DisplayName", "测试船"),
39 new XElement("CubeBlocks", Block("1234567890123456789", "脚本一", source1), Block("-234567890123456789", "脚本二", source2), Block("333", "未选择", "// untouched"),
40 new XElement("Projector", new XElement("CubeBlocks", Block("444", "投影蓝图", "// blueprint"))))))));
41 string sector = Path.Combine(world, OfflineWorldStore.SectorName);
42 using (var writer = XmlWriter.Create(sector, new XmlWriterSettings { Encoding = new UTF8Encoding(true), Indent = true, NewLineChars = "\r\n", NewLineHandling = NewLineHandling.Entitize })) document.Save(writer);
43 byte[] original = File.ReadAllBytes(sector);
44 var originalOther = Directory.GetFiles(world).Where(x => x != sector).ToDictionary(x => x, OfflineWorldStore.FileSha);
45 var guard = new WorldGuard(); var store = new OfflineWorldStore(guard, data);
46 JsonElement ToJson(object value) => JsonSerializer.SerializeToElement(value, Json);
47 var inspection = ToJson(store.Inspect(world));
48 Check(inspection.GetProperty("programs").GetArrayLength() == 3, "offline inspect finds actual PBs and excludes projected blueprint PBs");
49 Check(ToJson(store.ReadProgram(world, "1234567890123456789")).GetProperty("source").GetString() == source1, "offline read preserves Unicode and CRLF source");
50 Check(ToJson(store.List(root)).GetProperty("worlds").GetArrayLength() == 1, "offline list locates save folders without endpoints");
51 string script1 = Path.Combine(root, "one.cs"), script2 = Path.Combine(root, "two.cs");
52 const string next1 = "// new 中文\r\nvoid Main(){Echo(\"a < b & c\");}", next2 = "// new two\nvoid Main(){}";
53 File.WriteAllText(script1, next1); File.WriteAllText(script2, next2);
54 var manifest = new WorldDeployment(1, world, OfflineWorldStore.Sha(original), [
55 new("1234567890123456789", OfflineWorldStore.Sha(source1), "one.cs", OfflineWorldStore.Sha(next1)),
56 new("-234567890123456789", OfflineWorldStore.Sha(source2), "two.cs", OfflineWorldStore.Sha(next2))]);
57 string manifestFile = Path.Combine(root, "deploy.json");
58 void WriteManifest(WorldDeployment value) => File.WriteAllText(manifestFile, JsonSerializer.Serialize(value, Json));
59 WriteManifest(manifest);
60 var preview = store.Deploy(manifestFile);
61 Check(!preview.Applied && preview.Changes.Length == 2 && File.ReadAllBytes(sector).SequenceEqual(original) && File.Exists(Path.Combine(world, OfflineWorldStore.SectorName + "B5")), "offline deployment defaults to dry run with exact selection and no save writes");
62 var applied = store.Deploy(manifestFile, true);
63 Check(applied.Applied && OfflineWorldStore.FileSha(sector) == applied.AfterSectorSha256 && applied.Changes.All(x => x.Characters > 0), "offline batch commits exact source hashes for both signed Int64 IDs");
64 Check(!File.Exists(Path.Combine(world, OfflineWorldStore.SectorName + "B5")) && File.Exists(Path.Combine(world, OfflineWorldStore.SectorName + "Banana")), "offline deployment invalidates binary sector cache but preserves similarly named unrelated file");
65 var finalDoc = XDocument.Load(sector, LoadOptions.PreserveWhitespace);
66 var oldDoc = XDocument.Load(new MemoryStream(original), LoadOptions.PreserveWhitespace);
67 foreach (var element in finalDoc.Descendants("Program").Zip(oldDoc.Descendants("Program"))) element.First.Value = element.Second.Value;
68 Check(XNode.DeepEquals(finalDoc, oldDoc) && originalOther.Where(x => !x.Key.EndsWith("sbsB5")).All(x => OfflineWorldStore.FileSha(x.Key) == x.Value), "deployment preserves Storage, configuration, enablement, unselected scripts, hardware, voxels and checkpoint");
69 Check(File.ReadAllBytes(Path.Combine(applied.BackupPath!, "files", OfflineWorldStore.SectorName)).SequenceEqual(original) && File.Exists(Path.Combine(applied.BackupPath!, "files", "planet.vx2")) && !Directory.Exists(Path.Combine(applied.BackupPath!, "files", "Backup")), "deployment snapshot contains exact original sector, cache and all active files without recursive game history");
70 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "stale expected sector hash prevents repeated deployment");
71 File.AppendAllText(Path.Combine(world, "Sandbox_config.sbc"), "<!-- user edit -->");
72 Throws<OfflineWorldException>(() => store.Restore(applied.BackupPath!), "rollback refuses to overwrite subsequent game or user progress");
73 File.Copy(Path.Combine(applied.BackupPath!, "files", "Sandbox_config.sbc"), Path.Combine(world, "Sandbox_config.sbc"), true);
74 store.Restore(applied.BackupPath!);
75 Check(File.ReadAllBytes(sector).SequenceEqual(original) && originalOther.All(x => OfflineWorldStore.FileSha(x.Key) == x.Value), "rollback restores original XML and binary cache byte for byte");
76
77 WriteManifest(manifest with { Scripts = [manifest.Scripts[0] with { ExpectedSha256 = new string('a', 64) }] });
78 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "offline batch rejects stale per-block source hash");
79 WriteManifest(manifest with { Scripts = [manifest.Scripts[0] with { SourceSha256 = new string('b', 64) }] });
80 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "offline batch rejects changed input source before mutation");
81 WriteManifest(manifest with { Scripts = [manifest.Scripts[0], manifest.Scripts[0]] });
82 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "offline batch rejects duplicate block IDs");
83 WriteManifest(manifest with { Scripts = [manifest.Scripts[0] with { EntityId = "444" }] });
84 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "offline deploy cannot target projected blueprint blocks");
85 WriteManifest(manifest);
86 var host = new GameHostStatus(123, "C:\\game\\SpaceEngineers.exe", DateTimeOffset.UtcNow, false, "loaded_or_loading", world, null, "fixture");
87 guard.Hosts = [host];
88 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "loaded target world is rejected without needing a plugin endpoint");
89 guard.Hosts = [host with { State = "unknown", WorldPath = null }];
90 Throws<OfflineWorldException>(() => store.Backup(world), "unknown game state blocks offline mutation and inconsistent snapshots");
91 guard.Hosts = [host with { WorldPath = world + "-other" }];
92 Check(!store.Deploy(manifestFile).Applied, "another identified loaded world permits independent offline deployment preview");
93 guard.Hosts = [];
94 guard.Checks = 0;
95 FileStream? held = null;
96 guard.OnCheck = n => { if (n == 4) held = new FileStream(sector, FileMode.Open, FileAccess.Read, FileShare.Read); };
97 try
98 {
99 try { store.Deploy(manifestFile, true); throw new Exception("Expected atomic replace failure"); }
100 catch (OfflineWorldException ex) { Check(ex.Code == "deployment_rolled_back", "failed atomic replacement recovers cache automatically and reports rollback"); }
101 }
102 finally { held?.Dispose(); guard.OnCheck = null; }
103 Check(File.ReadAllBytes(sector).SequenceEqual(original) && originalOther.All(x => OfflineWorldStore.FileSha(x.Key) == x.Value), "failed deployment leaves all active files byte identical");
104 guard.Checks = 0;
105 guard.OnCheck = n => { if (n == 4) guard.Hosts = [host]; };
106 Throws<OfflineWorldException>(() => store.Deploy(manifestFile, true), "world loaded during backup is detected again before commit");
107 guard.OnCheck = null; guard.Hosts = [];
108 Check(File.ReadAllBytes(sector).SequenceEqual(original), "late world-use conflict leaves source unchanged");
109 Throws<OfflineWorldException>(() => store.Backup(world, Path.Combine(world, "nested-backup")), "backup destination cannot recurse into target save");
110 var snapshot = ToJson(store.Backup(world));
111 Check(snapshot.GetProperty("snapshot").GetProperty("files").GetArrayLength() == 6, "standalone offline backup covers all active files");
112
113 var parsed = HostStatus.ParseLog(new StringReader($"Environment.CommandLine: \"{host.ExecutablePath}\"\nLoading session: {world}\n"), host with { State = "unknown", WorldPath = null }, "fixture.log");
114 Check(parsed.State == "loaded_or_loading" && parsed.WorldPath == world, "host log identifies the current save only after executable identity matches");
115 parsed = HostStatus.ParseLog(new StringReader($"Environment.CommandLine: \"{host.ExecutablePath}\"\nLoading session: {world}\nMySession::Unload START\n"), host with { State = "unknown", WorldPath = null }, "fixture.log");
116 Check(parsed.WorldPath == world, "host protects the previous world until unload completes");
117 parsed = HostStatus.ParseLog(new StringReader($"Environment.CommandLine: \"{host.ExecutablePath}\"\nLoading session: {world}\nMySession::Unload END\n"), host with { State = "unknown", WorldPath = null }, "fixture.log");
118 Check(parsed.State == "unloaded" && parsed.WorldPath == null, "host recognizes completed unload without UI access");
119 parsed = HostStatus.ParseLog(new StringReader($"Environment.CommandLine: \"C:\\other.exe\"\nLoading session: {world}\n"), host with { State = "unknown", WorldPath = null }, "fixture.log");
120 Check(parsed.State == "unknown", "host rejects logs belonging to a different executable");
121
122 var cli = new CliApplication(worlds: store, host: guard);
123 using var stdout = new StringWriter(); using var stderr = new StringWriter();
124 int exit = await cli.RunAsync(["world", "deploy", "--manifest", manifestFile], stdout, stderr);
125 using var cliResult = JsonDocument.Parse(stdout.ToString());
126 Check(exit == 0 && !cliResult.RootElement.GetProperty("applied").GetBoolean() && stderr.ToString().Length == 0, "offline CLI returns structured dry run without resolving an Agent pipe");
127 }
128 }
Modified tests/XFE.SeAgent.Tests/Program.cs +3 -2
@@ -13,7 +13,7 @@ using XFE.SeAgent.Plugin.Transport;
13 13
14 14 namespace XFE.SeAgent.Tests;
15 15
16 internal static class Program
16 internal static partial class Program
17 17 {
18 18 private static readonly string Fixtures = Path.Combine(Path.GetTempPath(), "XfeSeAgentTests", Guid.NewGuid().ToString("N"));
19 19 private static readonly List<string> Checks = [];
@@ -25,6 +25,7 @@ internal static class Program
25 25 try
26 26 {
27 27 TestProtocol(); TestEndpoints();
28 await TestOfflineWorldsAsync();
28 29 await TestClientBoundariesAsync();
29 30 await TestTransportAndCliAsync();
30 31 Console.WriteLine($"PASS {Checks.Count} checks; fixtures: {Fixtures}");
@@ -174,7 +175,7 @@ internal static class Program
174 175 using var json = JsonDocument.Parse(output.ToString());
175 176 return (code, json.RootElement.Clone(), error.ToString());
176 177 }
177 var help = await Run("--help"); Check(help.Exit == 0 && help.Json.GetProperty("commands").GetArrayLength() == 4 && help.Error.Length == 0, "CLI help is one valid JSON document with no stdout diagnostics");
178 var help = await Run("--help"); Check(help.Exit == 0 && help.Json.GetProperty("commands").GetArrayLength() == 11 && help.Error.Length == 0, "CLI help is one valid JSON document with no stdout diagnostics");
178 179 string parameters = Path.Combine(Fixtures, "parameters.json"); File.WriteAllText(parameters, "{\"entityId\":\"1234567890123456789\",\"argument\":\"中文 参数\"}");
179 180 var called = await Run("call", "pb.run", "--params", "@" + parameters, "--pipe", pipe);
180 181 Check(called.Exit == 0 && called.Error.Length == 0 && called.Json.GetProperty("result").GetProperty("parameters").GetProperty("argument").GetString() == "中文 参数", "CLI call reads @JSON file and preserves Unicode parameters");
Modified tests/XFE.SeAgent.Tests/README.md +2 -0
@@ -9,3 +9,5 @@ dotnet run --project tests/XFE.SeAgent.Tests -c Release
9 9 覆盖请求校验及大小边界、当前用户管道 ACL、四客户端并发、响应关联、只有 Pump 才执行、过期/断开/卸载后不补执行、进程端点真实性、客户端取消与响应校验、真实 CLI 进程 JSON 输出、部署哈希参数及 JSONL 采样。临时夹具和结果文件写入 `%TEMP%/XfeSeAgentTests`。
10 10
11 11 这套验证不替代真实游戏 API 和存档中的集成测试。
12
13 离线存档夹具另覆盖批量源码更新、Unicode/CRLF、Int64 ID、排除投影蓝图、其他 XML 与文件不变、完整备份、二进制缓存失效、逐字节回滚、原子替换失败后的自动恢复、游戏中途加载及后续修改冲突。主机日志检查覆盖可执行文件匹配、加载中保护和卸载完成识别。全部文件在临时目录生成,不使用用户存档。