返回提交历史
Modified
README.md
+2
-0
Modified
src/XFE.SeAgent.Cli/CliApplication.cs
+38
-6
Added
src/XFE.SeAgent.Cli/HostStatus.cs
+116
-0
Added
src/XFE.SeAgent.Cli/OfflineWorldStore.cs
+329
-0
Modified
src/XFE.SeAgent.Cli/README.md
+45
-2
Added
tests/XFE.SeAgent.Tests/OfflineWorldTests.cs
+128
-0
Modified
tests/XFE.SeAgent.Tests/Program.cs
+3
-2
Modified
tests/XFE.SeAgent.Tests/README.md
+2
-0
SpaceEngineersModDev/XFE.SpaceEngineers.AgentBridge
Add offline world deployment, backups and host diagnostics
18cdf7b
代码差异
8 个文件
+663
-10
@@ -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)。
@@ -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
{
@@ -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
}
@@ -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
}
@@ -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` 取消实时等待;游戏线程中已经开始的操作无法通过关闭客户端回滚。离线写入事务会完成提交或恢复后才返回,操作期间请勿加载目标存档。
@@ -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
}
@@ -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");
@@ -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 与文件不变、完整备份、二进制缓存失效、逐字节回滚、原子替换失败后的自动恢复、游戏中途加载及后续修改冲突。主机日志检查覆盖可执行文件匹配、加载中保护和卸载完成识别。全部文件在临时目录生成,不使用用户存档。