返回提交历史
Modified
README.md
+12
-17
Modified
docs/API.md
+41
-2
Deleted
docs/DEBUGGING.md
+0
-115
Modified
src/XFE.SeAgent.Plugin/AgentPlugin.cs
+1
-1
Modified
src/XFE.SeAgent.Plugin/Game/GameDebugApi.cs
+2
-0
Added
src/XFE.SeAgent.Plugin/Game/InventoryOperations.cs
+162
-0
Modified
src/XFE.SeAgent.Plugin/Game/WorldQueries.cs
+2
-1
Deleted
tests/test_world_copy.py
+0
-213
Deleted
tools/prepare_debug_world.py
+0
-103
Deleted
tools/trace_ams.py
+0
-99
SpaceEngineersModDev/XFE.SpaceEngineers.AgentBridge
Add conveyor diagnostics and separate script-specific tooling
372487b
代码差异
10 个文件
+220
-551
@@ -18,16 +18,10 @@
18
18
19
19
读取接口可用于观察当前世界。部署脚本、执行命令、修改设备、主动相机探测、暂停和物理停止只接受明确授权的**离线测试副本**。在主菜单也可加载已授权的副本,不自动卸载另一个正在运行的世界。
20
20
21
```powershell
22
python tools/prepare_debug_world.py
23
```
24
25
该脚本需要 Python 3.11+,按修改时间寻找包含 AMS 编程块的存档,排除历史备份并创建副本,将副本设为离线、关闭自动保存,记录原文件 SHA-256 与编程块清单。也可传入 `--source <存档目录>`。原始存档不写入。结果位于 `artifacts/world-preparation.json`。
26
27
授权配置位于 `%LOCALAPPDATA%/XFE/SpaceEngineersAgent/config.json`,只记录副本绝对路径:
21
先准备独立的离线测试副本,再将副本的绝对路径写入 `%LOCALAPPDATA%/XFE/SpaceEngineersAgent/config.json`:
28
22
29
23
```json
30
{ "allowedWorldPaths": ["C:\\path\\to\\XFE Agent Debug AMS"] }
24
{ "allowedWorldPaths": ["C:\\path\\to\\DebugWorldCopy"] }
31
25
```
32
26
33
27
配置在插件初始化时读取。修改授权列表后需要重启游戏。
@@ -42,13 +36,13 @@ $cli = './src/XFE.SeAgent.Cli/bin/Release/net10.0/xfe-se.exe'
42
36
& $cli call agent.capabilities
43
37
& $cli call world.status
44
38
& $cli call world.load --params '@artifacts/load-world.json'
45
& $cli call blocks.list --params '{"name":"XFEAMS","limit":64}'
46
& $cli call pb.read --params '{"entityId":"139184150457569825"}'
47
& $cli deploy --block 139184150457569825 --file 'C:\path\script.cs' --expected-hash '<pb.read 返回的 sha256>'
48
& $cli watch --block 139184150457569825 --seconds 60 --interval 1 --out artifacts/miner7.jsonl
39
& $cli call blocks.list --params '{"name":"调试编程块","limit":64}'
40
& $cli call pb.read --params '{"entityId":"1234567890123456789"}'
41
& $cli deploy --block 1234567890123456789 --file 'C:\path\script.cs' --expected-hash '<pb.read 返回的 sha256>'
42
& $cli watch --block 1234567890123456789 --seconds 60 --interval 1 --out artifacts/program.jsonl
49
43
```
50
44
51
`load-world.json` 格式为 `{ "path": "测试副本绝对路径" }`。实体编号使用十进制字符串,避免 JavaScript Number 丢失 64 位精度。标准输出仅为 JSON;错误详情在标准错误输出,同时返回非零退出码。完整 CLI 参数见 [客户端说明](src/XFE.SeAgent.Cli/README.md),接口参数见 [API 参考](docs/API.md)。
45
`load-world.json` 格式为 `{ "path": "测试副本绝对路径" }`。示例实体编号须替换为 `blocks.list` 返回的真实编号;使用十进制字符串,避免 JavaScript Number 丢失 64 位精度。标准输出仅为 JSON;错误详情在标准错误输出,同时返回非零退出码。完整 CLI 参数见 [客户端说明](src/XFE.SeAgent.Cli/README.md),接口参数见 [API 参考](docs/API.md)。
52
46
53
47
## 调试能力
54
48
@@ -87,13 +81,14 @@ PB 部署备份保存在测试存档的 `Storage/XFE.AgentBridge/Backups` 中,
87
81
88
82
过期、断开或已取消的排队请求不会再触发操作。已经开始执行的游戏操作无法从后台线程安全中断;遇到此类超时应先查询实际状态,再决定是否重试。主动相机扫描会消耗真实充能;排障时应与业务脚本的扫描时间区分。
89
83
90
## 验证与调试记录
84
## 验证
91
85
92
86
```powershell
93
87
dotnet run --project tests/XFE.SeAgent.Tests -c Release
94
python -m unittest discover -s tests -p test_world_copy.py -v
95
88
```
96
89
97
自动测试覆盖协议边界、当前用户管道、真实队列与取消行为、客户端发现/调用/部署/连续记录,以及隔离目录中的存档复制。游戏 API 编译和游戏内实测分开记录,见 [实测记录](docs/DEBUGGING.md)。`artifacts` 保存本机原始证据,默认不提交存档、遥测中的完整源码或个人路径。
90
自动测试覆盖协议边界、当前用户管道、真实队列与取消行为、客户端发现、调用、部署和连续记录。验证范围与运行方式见 [测试说明](tests/XFE.SeAgent.Tests/README.md)。`artifacts` 可保存插件本机验证结果,默认不提交存档、遥测中的完整源码或个人路径。
91
92
通用记录使用 `xfe-se watch`。处理 `pb.inspect` 结果时须保留字段名称的大小写,Python JSON 或 PowerShell 7 的 `ConvertFrom-Json -AsHashtable` 均可。
98
93
99
`tools/trace_ams.py` 可同时记录 AMS 的真实脚本状态、扫描和绕行字段与网格速度。它只接受脚本哈希匹配的压缩字段映射;更新 AMS 后需重新核对映射。通用记录使用 `xfe-se watch`,不依赖 AMS 版本。处理 `pb.inspect` 结果时须保留字段名称的大小写,Python JSON 或 PowerShell 7 的 `ConvertFrom-Json -AsHashtable` 均可。
94
业务脚本的构建、专用字段映射、实机回放和验收记录由对应脚本项目维护。本仓库提供通用游戏接口、命令行客户端及插件验证。
@@ -164,6 +164,44 @@ JSON 字段名区分大小写。请求最大深度 32,不接受重复属性名
164
164
165
165
上述表是 CLR 属性类型概念;`properties[].type` 保留游戏提供的原始类型名称。其他类型不能通过此接口写入。没有提供任意 CLR 方法调用、表达式求值或调用方指定的反射路径。
166
166
167
### 库存路线:`inventory.route`(只读)
168
169
查询指定来源库存到指定目标库存的输送能力,不移动物品、不切换设备:
170
171
```json
172
{"sourceId":"<来源方块ID>","targetId":"<目标方块ID>","sourceInventoryIndex":0,"targetInventoryIndex":0,"itemType":"MyObjectBuilder_Ore/Stone"}
173
```
174
175
`sourceId`、`targetId` 必填,接受非零 Int64 十进制字符串或 JSON 整数,拒绝浮点数、空白、正号及溢出;推荐始终使用字符串。库存索引默认 0,必须是 0–15 的 JSON 整数,而且对应方块确实拥有该库存。`itemType` 默认 `MyObjectBuilder_Ore/Stone`,必须是当前世界存在的具体物品定义 `MyObjectBuilder_Type/Subtype`,不能使用所有子类型通配。未知参数、显式 null 和错误类型均拒绝。
176
177
返回字段:
178
179
| 字段 | 含义 |
180
| --- | --- |
181
| `source`, `target` | 各含 `entityId`, `gridId`, `index`, `massKg`, `volumeM3`, `maxVolumeM3`, `freeVolumeM3`, `isFull`, `canPutItems`, `itemCount`。空余体积为非负的最大体积减当前体积。 |
182
| `itemType`, `amountTested` | 实际解析的具体物品定义;测试加入数量固定为 **1 个游戏库存单位**,不保证所有物品类型都等于 1 kg。 |
183
| `isConnectedTo` | 来源库存调用 `IsConnectedTo(target)` 的原始结果。 |
184
| `canTransferItemTo` | 来源库存调用 `CanTransferItemTo(target,itemType)` 的原始结果,方向为 source→target。 |
185
| `targetCanAddOne` | 目标库存调用 `CanItemsBeAdded(1,itemType)` 的结果,考虑容量和库存约束。 |
186
187
拓扑连通、指定物品可传输和目标能容纳物品是不同检查。它们是本次查询的状态,不能代替实际转移前后库存的核对,也不返回经过了哪些分拣器的路线列表。
188
189
### 分拣器过滤器:`sorters.setFilters`(写权限)
190
191
明确替换**一个指定分拣器**的过滤模式和完整列表,调用游戏 `IMyConveyorSorter.SetFilter`。例如将实际查询得到的中央分拣器设为允许全部矿石:
192
193
```json
194
{"entityId":"<已确认的分拣器ID>","mode":"Whitelist","items":[{"itemId":"MyObjectBuilder_Ore","allSubTypes":true}]}
195
```
196
197
- `entityId` 规则同库存路线,且方块必须实现 `IMyConveyorSorter`。仍须通过本桥的授权离线测试副本写权限检查。
198
- `mode` 必填,精确为 `Whitelist` 或 `Blacklist`;`items` 必填,最多 128 项。空数组明确清空过滤条目,保留所指定的模式;白名单空列表不允许任何物品,黑名单空列表不排除任何物品。
199
- 每项是 `{itemId,allSubTypes}`。`allSubTypes` 可省略为 false,提供时必须是 JSON 布尔。false 要求当前世界存在的具体物品,如 `MyObjectBuilder_Ingot/Uranium`;true 要求物品类型且不指定子类型,如 `MyObjectBuilder_Ore`、`MyObjectBuilder_Ore/` 或游戏读回的 `MyObjectBuilder_Ore/(null)`。
200
- `itemId`/路线的 `itemType` 字符串最多 256 字符,禁止控制字符、首尾空白、多重 `/`、未注册或非物品类型。重复的有效过滤条目、未知参数、错误 JSON 类型均拒绝。全部参数校验完成后才调用一次 `SetFilter`。
201
- 此方法不改 `DrainAll`、`Enabled` 或其他属性,不修改任何其他分拣器,也不转移库存。列表是替换而非追加;应先从遥测保存实际的原模式与列表,再提交期望的完整清单。
202
203
返回 `{entityId,submitted:true,readbackMatchesRequest,previous,sorter}`。`previous` 是操作前读数;`sorter` 是操作后立即读回,结构同第 8 节分拣器遥测。`submitted` 表示已调用游戏 API;只有 `readbackMatchesRequest:true` 才表示本次读回已匹配请求。游戏的过滤设置可能经事件处理传播,如即时读回不一致,应再查遥测确认,不能把已提交当作已生效。重新提交原模式及 `previous.items` 可恢复清单;若 `previous.truncated:true`,该快照不是完整恢复数据。
204
167
205
## 6. 可编程方块(PB)
168
206
169
207
### `pb.read`
@@ -215,7 +253,7 @@ JSON 字段名区分大小写。请求最大深度 32,不接受重复属性名
215
253
- 字典输出 `[{key,value},...]`,不是以字符串键展开的 JSON 对象。枚举输出名称字符串;向量输出坐标对象;`MatrixD` 输出 `pose` 形状;`TimeSpan` 输出秒;普通原始数值字段仍为 JSON 数字,包括脚本自己的 `long` 字段。
216
254
- 文本/StringBuilder 最多 4096 字符,超出追加 `…`。可能出现 `<budget exhausted>`、`<depth limit: ...>`、`<reference>`、`<truncated>`、`<unavailable: ...>`、`<类型全名>` 或对象的 `$truncated:true`;这些标记不是脚本原始值。
217
255
218
**AMS 压缩脚本字段注意事项:**字段名是实际已编译脚本中的名字,压缩构建可能是 `a`、`A` 等短名,同一对象可能同时包含仅大小写不同的字段。`fields:["a"]` 与 `fields:["A"]` 不等价;不同构建也可能改变映射。先读取当前 `pb.inspect`/当前部署源码,再据此选择真实字段,不要推测它们对应未压缩源码中的哪个成员。
256
**压缩脚本字段注意事项:**字段名是实际已编译脚本中的名字,压缩构建可能是 `a`、`A` 等短名,同一对象可能同时包含仅大小写不同的字段。`fields:["a"]` 与 `fields:["A"]` 不等价;不同构建也可能改变映射。先读取当前 `pb.inspect`/当前部署源码,再据此选择真实字段,不要推测它们对应未压缩源码中的哪个成员。
219
257
220
258
客户端须用保留大小写的 JSON 解析器/字典。PowerShell 默认 `ConvertFrom-Json` 转为属性对象时不能可靠表示 `a`/`A` 这样的键组合;可使用 PowerShell 7 的 `ConvertFrom-Json -AsHashtable`,或 `System.Text.Json.JsonDocument` 逐个读取精确属性名。不要先用不区分大小写的对象反序列化,再尝试恢复字段。处理脚本反射出的 Int64 数字时也要保留整数精度。
221
259
@@ -259,13 +297,14 @@ JSON 字段名区分大小写。请求最大深度 32,不接受重复属性名
259
297
| `battery` | `storedMWh`, `maxStoredMWh`, `inputMW`, `outputMW`, `chargeMode`, `charging`。 |
260
298
| `thrust` | `currentN`, `maximumN`, `maxEffectiveN`, `overrideN`, `overrideRatio`, `gridDirection`, `forceDirection`。`forceDirection` 为该推进器世界矩阵的 Backward。 |
261
299
| `gyro` | `override`, `power`, `yaw`, `pitch`, `roll`。 |
300
| `sorter` | `mode`, `drainAll`, `items:[{itemId,allSubTypes}]`, `total`, `truncated`。模式来自 `Mode`,排空开关来自 `DrainAll`,过滤列表来自 `GetFilterList`;最多输出 128 项。`itemId` 保留游戏 `MyDefinitionId.ToString()` 原始值,类型通配项可能带 `/(null)`;应同时读取 `allSubTypes`,不能把它误认为名为“(null)”的具体物品。即使关闭 `includeInventoryItems`,仍返回本字段。 |
262
301
| `connector` | `status`, `connected`, `connectable`, `otherConnectorId`(无对端为 null), `throwOut`, `collectAll`, `pullStrength`;实际游戏连接器另有下述约束点、吸附和交易字段。 |
263
302
| `camera` | `enabledRaycast`, `availableScanRange`, `coneLimitDegrees`, `distanceLimit`。 |
264
303
| `gasTank` | `capacity`, `filledRatio`, `stockpile`。 |
265
304
| `flight` | `linearVelocity`, `angularVelocity`, `speed`, `naturalGravity`, `artificialGravity`, `totalMassKg`, `physicalMassKg`, `baseMassKg`, `centerOfMass`, `dampeners`, `underControl`, `controlThrusters`, `moveIndicator`, `rotationIndicator:{x,y}`, `rollIndicator`。仅船舶控制器提供。 |
266
305
| `programmableBlock` | `sha256`, `compileErrors`(布尔), `hasInstance`, `runtime`(无实例为 null), `echo`(最多 8192 字符后加 `…`), `terminationReason`。此处不是 `pb.read` 的 `hasCompileErrors` 字段名。 |
267
306
| `inventories` | 有库存时输出数组,每方块最多 16 个库存;每项含 `index`, `massKg`, `volumeM3`, `maxVolumeM3`, `itemCount`。展开物品时还有 `items:[{itemId,type,subtype,amount}]`(最多 128 项)和 `truncated`。 |
268
| `cargoInventory` | 有库存方块的分类标记;仅货箱、钻头、连接器为 true。反应堆、氢氧制造机等仍可返回库存,但此标记为 false;统计矿机待卸货库存时可据此排除燃料/生产库存。 |
307
| `cargoInventory` | 有库存方块的分类标记;货箱、钻头、连接器、分拣器缓存为 true。分拣器里的矿石需要计入采样守恒及卸货,内部转移不应被误判为矿物消失。反应堆、氢氧制造机等仍可返回库存,但此标记为 false;统计待卸矿石时还应过滤 `MyObjectBuilder_Ore`,保留铀锭等运行物资。 |
269
308
| `screens` | 每方块最多 16 个屏幕,每项 `index`, `name`, `displayName`, `contentType`, `text`, `script`, `surfaceSize:{x,y}`;`text` 最多 8192 字符后加 `…`。没有屏幕时可缺省整个字段。 |
270
309
271
310
### 连接器精确遥测
@@ -1,115 +0,0 @@
1
# 游戏内调试记录
2
3
日期:2026-09-14。游戏:Space Engineers 1.210.014,Windows x64。
4
5
本报告通过 XFE 自研加载器实际加载 `XFE.SeAgent.Plugin.dll`,由 `xfe-se` 读取正在运行的游戏。实测结论来自连续遥测、编程块实例和游戏返回值;模拟测试另行说明。
6
7
## 环境与数据保护
8
9
- 测试存档包含 13 个 AMS 编程块:1 个舰队主控、12 台矿机。写操作仅授权调试副本;副本配置为离线、自动保存间隔为零。
10
- 首轮副本为 `XFE Agent Debug AMS 20260914-172143`。结束后复核原存档 25 个文件,哈希全部一致,没有新增或删除文件;通过 `world.exit {"save":true}` 保存副本并正常退出。
11
- 本轮使用新的 `XFE Agent Debug AMS 20260914-182847` 副本;准备记录为 `artifacts/verification-world-preparation.json`。该副本完成探索、两次 7 号返航及后续 2 号捕获修复复测。
12
- 收尾核对 13 个实际 PB 的源码摘要均与最终构建清单相符,均有编译实例且无编译错误。原存档按准备阶段相同范围复核(排除已有 `Backup` 目录),25 个文件哈希及清单完全一致。副本保存成功后正常退出,游戏进程已结束。核对结果保存在 `verification-live-final-programs.json`、`verification-final-audit.json` 和 `verification-game-final-exit.json`。
13
- AMS 原工作区存在用户尚未提交的修改,修复在独立工作树完成,保留原工作区。私人存档、完整实例快照和原始飞行记录保留在本地 `artifacts/`,不随源码提交。
14
15
## 已验证的插件能力
16
17
| 检查 | 实测结果 |
18
| --- | --- |
19
| 加载器、游戏线程 Update、带关联 ID 的管道请求 | 实际加载成功,多次启动均正常建立端点 |
20
| 授权副本加载 | 从主菜单加载成功,`ready=true`、`onlineMode=OFFLINE`、`canMutate=true` |
21
| PB 枚举、源码与编译状态 | 13 个编程块均有实例,基线无编译错误;部署后可核对源码摘要与编译结果 |
22
| 受限实例检查 | 可读 AMS 任务、飞控、事件和游戏编译器生成的 MemorySafe 集合 |
23
| 设备遥测 | 可读相机、推力覆盖、连接器约束点、库存、网格速度以及含装甲的船体完整度 |
24
| 源码部署、备份与命令 | SHA-256 前置校验、持久备份、游戏内编译及继续/探索/返航命令均实测成功 |
25
| 截图、暂停和保存 | 生成并人工核对真实 PNG,暂停状态和副本保存可读取确认 |
26
| 正常退出 | `world.exit` 返回保存成功,随后插件 Dispose、游戏进程退出,加载器记录正常退出 |
27
28
## 首轮发现:不可达探索入口反复绕行
29
30
7 号的探索入口距离新鲜的母舰射线命中点仅 **1.54 m**,矿机避碰半径约 **4.88 m**。连续 **52.53 秒、43 个样本**显示约五次局部绕行;脚本时间超过 503 秒时仍执行同一任务。期间实际速度曾从 7.32 降至 0.27 m/s,说明问题包含无法结束任务,不能简单归结为没有制动。
31
32
原 `Transit` 超时依赖瞬时 `flight.Blocked`。飞控不断找到局部绕点时,该标志会清除,即使长期没有接近入口也不会报告受阻。修复改为:连续 45 秒没有累计接近入口至少 2 m,先制动,再报告 `Blocked`,等待主控续派;正常缓慢进展和同帧发现小行星仍优先处理。
33
34
首轮部署后只观察到返航,没有再次进入探索入口,因此当时尚不能确认超时与续派有效。以下新副本记录补上了这项实机验证。
35
36
## 本轮探索、避让与返航结果
37
38
| 场景 | 实测结果 | 本地证据 |
39
| --- | --- | --- |
40
| 不可达入口超时 | `job-346` 在最后一次有效接近后 **45.016 秒**报告 `Blocked`;报告后 **4.033 秒内**首次采样确认已执行下一任务 | `verify-flight-02.jsonl`、`verification-metrics-stage1.json` |
41
| 同入口任务分组跳过 | 同入口共 **42 个任务**由原来的 2 个已受阻、40 个未完成,变为全部受阻;随后分配 `job-304`,入口移动 **100.778 m** | `verify-group-skip-flight.jsonl`、`verify-group-planner-storage.json` |
42
| 2 号经过暂停的 9 号 | 旧记录反复因 9 号触发避让;船体窄筛修复后,2 号实际通过该位置到达泊位末段,新记录没有再次出现避让 9 号的原因文本 | `verify-two-return-flight.jsonl`、`verify-final-two-flight.jsonl` |
43
| 2 号末段捕获修复 | 新版实际穿过旧标定终点并连接到分配泊位,最终“待命中”;连续记录没有再次进入末段停滞或退让 | `verify-capture-fix-connectors.jsonl`、`verify-capture-fix-two-flight.jsonl` |
44
| 7 号正常探索 | 连续记录包含 **27 个不同任务的 `SurveyEmpty` 报告**,每个都有实际 `Scan` 数据,任务为 `job-304` 至 `job-330`,正常续派 | `verify-final-seven-explore.jsonl` |
45
| 7 号返航与再次出航 | 第一次两端确认 `Connected` 后正常离港并继续探索;探索结束后的第二次返航也由两端确认连接,最终状态为 `Servicing`,原因“待命中” | `verify-final-connectors.jsonl`、`verify-seven-return-connectors.jsonl`、`verify-final-seven-explore.jsonl` |
46
| 停靠后推进器 | 最终版本的 2 号、7 号各 **16 个推进器全部 `enabled=false`**,实际推力和推力覆盖均为零 | `verify-two-docked-hardware-final.json`、`verify-seven-docked-hardware-9553.json` |
47
48
**计时口径:**45.016 秒使用 PB 内部进展时间与报告状态时间相减;4.033 秒是报告到首次观察到下一任务的采样上界,不能当作精确的调度耗时。下一任务记录的状态起始时间距报告约 2.783 秒。42 个是整组任务数,其中本次新转为受阻的是 40 个。
49
50
上述阶段的矿机源码摘要前缀分别为:超时验证 `b191430cf9ab`,分组跳过验证 `2aed54ad323c`,避让改善和本节返航验证 `1146850d83e9`。不能将不同部署阶段的遥测混为同一版本。
51
52
### 2 号与 9 号的球形误判
53
54
旧筛选使用的两船合计球形间距约 **10.263 m**。两船姿态对齐、2 号沿该对接轴直行时,横向中心间隔约 **4.905 m**,由实际船体边界计算的净距约 **0.405 m**,并不存在球体重叠所暗示的船体相交。旧逻辑因此反复让行,妨碍接近。
55
56
改进后的筛选使用真实船体边界和连续时间的有向包围盒检查,并保留旋转、遥测年龄和旧版遥测的保守处理。本轮实机证明 2 号能通过这一具体位置;0.405 m 的结论仅适用于所核对的对齐直线路径,不代表转弯、横移或任意姿态均有同样净距。几何输入和来源记录在 `verification-review-stage2.json`。
57
58
### 船体完整度前后核对
59
60
逐一比较 `verify-final-hull-baseline-predeploy.json` 与 `verify-final-hull-after-survey.json`,并在捕获修复后再次核对 `verify-all-hull-final.json`:**13 个网格的 ID、方块数和完整度对象全部一致**,检查均未截断,包含装甲块。
61
62
| 网格 | 数量 | 每个网格方块数 | 每个网格当前/最大完整度 | 损伤与变形 |
63
| --- | --- | --- | --- | --- |
64
| 矿机 | 12 | 85 | 47,056 / 47,056 | 当前伤害、累计待处理伤害、受损块、变形块均为零 |
65
| 母舰 | 1 | 1,310 | 5,389,514 / 5,389,514 | 同上 |
66
67
这些记录支持本轮前后没有船体完整度损失或变形;完整度不变本身不能证明绝无任何物理接触。
68
69
## 2 号末段:旧标定终点导致停滞
70
71
避让修复后,2 号已实际到达泊位末段。`verify-final-connectors.jsonl` 显示两端连接器均启用、正常工作且完好,所有者相同,接口轴向相反、横向偏差约毫米级;然而一直保持 `Unconnected`。
72
73
其约束点间距曾降至 **0.302131 m**,对应连接器方块中心距约 **2.134378 m**,随后回退并稳定在约束点间距约 **0.395 m**、中心距约 **2.228 m**。`verify-final-two-flight.jsonl` 在约一分钟内反复记录“距离 0.0”、指令速度 **0.5 m/s**,而实际速度接近零,最后触发对接超时退让。
74
75
代码中旧目标恰好位于标定接触点。飞控到达或越过目标后,位移方向会改变;极近距离还会使用船体前向作为备用方向。对于后置连接器,这会使接近指令在旧终点附近反向。仅提高速度标量的下限,不能保证持续朝接口内侧移动。实测约束点闭合速度曾依次约为 **0.510、0.456、0.147 m/s**,之后间距增大,符合该停滞机制;这些速度由相邻物理帧的约束点间距计算,没有把指令速度当作实际速度。
76
77
作为同一现场的对照,7 号第一次返航在中心距约 **2.009880 m** 时仍未连接,下一样本在约 **1.993233 m** 时已由两端确认连接;当时约束点间距约 **0.160985 m**。第二次返航也成功,最终硬件快照约束点间距约 **0.165789 m**。这些是此现场的观测值,不能推广为所有连接器的固定捕获阈值。
78
79
### 捕获距离的判定边界
80
81
对本机实际 `Sandbox.Game.dll` 的只读 IL 核对表明:`TryAttach` 中约束点距离平方小于 **0.35** 是后续条件之一;其平方根约 **0.591608 m**,不能直接当作充分的自动捕获半径。
82
83
在检查这一距离之前,引擎还必须通过模型连接探测区域、候选网格和方块搜索找到另一连接器;之后还有工作状态、友好关系、物理状态、连接器类型和相向角度等条件。自动尝试捕获约每 40 个仿真帧进行一次,PB 的 `Connect()` 也不能把一个尚未建立磁性约束的任意连接器强制变为已连接。
84
85
因此,“约束点已近于 0.592 m”或“曾经在这个中心距离保存过标定”都不足以证明新一次接近必定能捕获。现有公开遥测不能确定 2 号具体未通过哪一项内部候选搜索条件;可以确认的是,旧飞控会停在尚未捕获的名义终点,而 7 号在继续靠近后成功连接。
86
87
### 2 号捕获修复:实机通过
88
89
新修复保留名义接触点用于姿态、误差和诊断,仅在已标定且完成姿态同步的接近阶段,将控制目标沿连接器轴向内延伸 **0.6 m**,避免在旧终点反向。若实际位置越过名义接触点内侧 **0.35 m** 仍未捕获,则先制动并进入退让;每次先检查 `Connectable`,已经磁性吸附时优先锁定。0.35 m 是退让触发线,并非忽略惯性的硬停止边界。方向依据母舰连接器轴线,针对后置、侧置、无捕获退让和 40 帧捕获相位的闭环回归已加入。
90
91
矿机源码摘要 `9553e0ec92de8fc93c814852f37ff088f579c038ca8bdcf6c06b57e0f44d28ab` 部署后的实机复测包含 **539 个连接器快照**及 **242 个 PB 飞行样本**。2 号本次成功锁定分配的母舰连接器,双方 `otherConnectorId` 相互匹配,并进入 `Servicing`、原因“待命中”;记录结束时保持连接。7 号在这段记录中也始终正确连接于自己的泊位。
92
93
| 观测点 | 结果 |
94
| --- | --- |
95
| 穿过旧标定终点 | 连接器中心距连续从 2.283269 → 2.172449 → 2.062360 → 1.952804 m,越过旧标定 2.226946 m,没有在该终点反转停住 |
96
| 穿越前后实际径向闭合速度 | 由约束点距离差与物理帧差计算,依次约 **0.5115、0.5081、0.5056 m/s** |
97
| 最后两个 PB 接近样本 | 实际速度 **0.511136、0.505106 m/s**,指令速度均为 0.5 m/s |
98
| 首次双方确认连接 | **11:21:05.8727451 UTC**,第 312 个连接器样本、物理帧 76233;前一未连接样本为 11:21:05.6561569 UTC,因此转换位于约 0.217 秒的采样区间内 |
99
| 首次 PB 确认待命 | 11:21:05.886378 UTC,`Servicing`、原因“待命中” |
100
| 连接后几何 | 首次连接中心距约 1.829180 m,约束点距离约 **0.003129 m**;这些是当前安装的实际结果,不是通用连接距离 |
101
| 本次复测损伤 | 2 号、7 号和母舰在全部 539 个快照中,方块数与完整度均未下降,无损伤或变形记录 |
102
103
原始证据为 `verify-capture-fix-connectors.jsonl` 与 `verify-capture-fix-two-flight.jsonl`。全部 64 个 `DockApproach` PB 样本的实际速度最小为 **0.505106 m/s**,末段正常持续靠近。紧接锁定前的另一个约束点区间平均闭合速度约 **0.494740 m/s**,跨越锁定的区间约 **0.009169 m/s**,其中可能包含制动、吸附和速度归零;不能据此把指令值当成任意时刻实际速度的硬下限,也不能省略这些较低的区间值。物理帧速度按每秒 60 帧换算,与墙钟采样间隔计算的速度分开。
104
105
这一复测验证了旧终点停滞问题的修复及 2 号实际完成捕获。它不意味着所有安装误差、捕获轮询相位和移动母舰条件都已穷尽验证。
106
107
## 调试工具修正与自动检查
108
109
本次工具调试同时修正了实时仿真速度读取(使用 `MyPhysics.SimulationRatio`)、MemorySafe 集合检查、库存 kg/m³ 单位和货物库存分类,并增加实际连接约束点、连接器状态及有上限的全船体完整度遥测。第一次 Windows 关闭窗口触发过游戏 IME 卸载异常,后续改为调用游戏自身的 `world.exit`,正常保存并退出已实测。
110
111
现有自动验证记录:完整 AgentBridge 解决方案 Release 构建零警告、零错误;**46 项 .NET 检查**通过,覆盖真实 Windows 管道、权限、队列、取消、响应大小、CLI 进程与记录;**9 项 Python 检查**通过,使用临时用户目录验证存档复制与原始文件保护。这些检查不替代实际飞行验收。
112
113
AMS 最终完整构建中英文各 **856/856 项测试**通过,八份导出逐一通过编译、角色、字符限制及摘要核验。中文矿机 **97002 字符**,摘要 `9553e0ec92de8fc93c814852f37ff088f579c038ca8bdcf6c06b57e0f44d28ab`;中文主控 **97973 字符**,摘要 `8a42840b6e3d73f578814d27e3fc3ccb8bfabc59231bc126b9008781f1122030`。英文矿机、主控分别 99403、99131 字符。完整记录为 `ams-build-9553e0ec-8a42840b.json`。
114
115
本报告尚未证明整套舰队长时间采矿、发现矿产后的钻探分配、多矿小行星显示及所有方向相机布局组合全部通过。27 个 `SurveyEmpty` 证明本轮真实扫描与续派,不代表已经找到矿产或完成采矿。
@@ -74,7 +74,7 @@ namespace XFE.SeAgent.Plugin
74
74
["mutationPolicy"] = "Only explicitly authorized offline test worlds; all game access runs on the game thread.",
75
75
["methods"] = new JArray("agent.ping", "agent.capabilities", "agent.events", "world.status", "world.load", "world.save", "world.pause", "world.exit",
76
76
"grids.list", "grids.get", "grid.stop", "blocks.list", "blocks.get", "blocks.actions", "blocks.action", "blocks.properties", "blocks.setProperty",
77
"pb.read", "pb.inspect", "pb.deploy", "pb.run", "cameras.scan", "telemetry.snapshot", "debug.screenshot")
77
"pb.read", "pb.inspect", "pb.deploy", "pb.run", "cameras.scan", "telemetry.snapshot", "inventory.route", "sorters.setFilters", "debug.screenshot")
78
78
};
79
79
if (method == "agent.events")
80
80
{
@@ -52,6 +52,8 @@ namespace XFE.SeAgent.Plugin.Game
52
52
case "blocks.action": RequireWritableWorld(); return ApplyAction(args);
53
53
case "blocks.properties": RequireWorld(); return ListProperties(args);
54
54
case "blocks.setProperty": RequireWritableWorld(); return SetProperty(args);
55
case "inventory.route": RequireWorld(); return InventoryRoute(args);
56
case "sorters.setFilters": RequireWritableWorld(); return SetSorterFilters(args);
55
57
case "pb.read": RequireWorld(); return ReadProgram(args);
56
58
case "pb.inspect": RequireWorld(); return InspectProgram(args);
57
59
case "pb.deploy": RequireWritableWorld(); return DeployProgram(args);
@@ -0,0 +1,162 @@
1
using System;
2
using System.Collections.Generic;
3
using System.Globalization;
4
using System.Linq;
5
using Newtonsoft.Json.Linq;
6
using Sandbox.Definitions;
7
using Sandbox.ModAPI.Ingame;
8
using VRage;
9
using VRage.Game;
10
using VRage.Game.ModAPI.Ingame;
11
using Terminal = Sandbox.ModAPI.Ingame.IMyTerminalBlock;
12
13
namespace XFE.SeAgent.Plugin.Game
14
{
15
public sealed partial class GameDebugApi
16
{
17
private JObject InventoryRoute(JObject args)
18
{
19
InventoryParameters(args, "sourceId", "targetId", "sourceInventoryIndex", "targetInventoryIndex", "itemType");
20
var sourceBlock = Block(InventoryEntityId(args, "sourceId"));
21
var targetBlock = Block(InventoryEntityId(args, "targetId"));
22
int sourceIndex = InventoryIndex(args, "sourceInventoryIndex");
23
int targetIndex = InventoryIndex(args, "targetInventoryIndex");
24
var source = InventoryAt(sourceBlock, sourceIndex);
25
var target = InventoryAt(targetBlock, targetIndex);
26
var definition = InventoryDefinition(args["itemType"] ?? new JValue("MyObjectBuilder_Ore/Stone"), false);
27
var itemType = MyItemType.Parse(definition.ToString());
28
return new JObject
29
{
30
["source"] = InventorySummary(sourceBlock, source, sourceIndex),
31
["target"] = InventorySummary(targetBlock, target, targetIndex),
32
["itemType"] = definition.ToString(), ["amountTested"] = 1,
33
["isConnectedTo"] = source.IsConnectedTo(target),
34
["canTransferItemTo"] = source.CanTransferItemTo(target, itemType),
35
["targetCanAddOne"] = target.CanItemsBeAdded((MyFixedPoint)1, itemType)
36
};
37
}
38
39
private JObject SetSorterFilters(JObject args)
40
{
41
InventoryParameters(args, "entityId", "mode", "items");
42
long entityId = InventoryEntityId(args, "entityId");
43
string modeText = Text(args, "mode");
44
MyConveyorSorterMode mode;
45
if (modeText == "Whitelist") mode = MyConveyorSorterMode.Whitelist;
46
else if (modeText == "Blacklist") mode = MyConveyorSorterMode.Blacklist;
47
else throw new ArgumentException("mode must be Whitelist or Blacklist.");
48
var items = args["items"] as JArray;
49
if (items == null || items.Count > 128) throw new ArgumentException("items must be an array of at most 128 filters; an empty array explicitly clears the filters.");
50
var filters = new List<MyInventoryItemFilter>();
51
var keys = new HashSet<string>(StringComparer.Ordinal);
52
foreach (var token in items)
53
{
54
var item = token as JObject ?? throw new ArgumentException("Each filter must be an object with itemId and optional allSubTypes.");
55
InventoryParameters(item, "itemId", "allSubTypes");
56
bool allSubTypes = false;
57
if (item["allSubTypes"] != null)
58
{
59
if (item["allSubTypes"].Type != JTokenType.Boolean) throw new ArgumentException("allSubTypes must be a JSON boolean.");
60
allSubTypes = (bool)item["allSubTypes"];
61
}
62
var definition = InventoryDefinition(item["itemId"], allSubTypes);
63
var filter = new MyInventoryItemFilter(definition, allSubTypes);
64
if (!keys.Add(FilterKey(filter))) throw new ArgumentException("Duplicate sorter filter.");
65
filters.Add(filter);
66
}
67
var sorter = Block(entityId) as IMyConveyorSorter ?? throw new ArgumentException("Entity is not a conveyor sorter.");
68
var previous = DescribeSorter(sorter);
69
// All inputs have been validated before this sole hardware mutation. SetFilter
70
// replaces only mode/list; DrainAll, Enabled, names and other sorters are untouched.
71
sorter.SetFilter(mode, filters);
72
var actual = new List<MyInventoryItemFilter>();
73
sorter.GetFilterList(actual);
74
bool matches = sorter.Mode == mode && actual.Count == filters.Count && actual.All(value => keys.Contains(FilterKey(value)));
75
_log("Replace sorter filters on " + Sid(entityId) + ": " + modeText + ", " + filters.Count + " entries; readback matches=" + matches);
76
return new JObject { ["entityId"] = Sid(entityId), ["submitted"] = true,
77
["readbackMatchesRequest"] = matches, ["previous"] = previous, ["sorter"] = DescribeSorter(sorter, actual) };
78
}
79
80
private static JObject DescribeSorter(IMyConveyorSorter sorter, List<MyInventoryItemFilter> filters = null)
81
{
82
if (filters == null) { filters = new List<MyInventoryItemFilter>(); sorter.GetFilterList(filters); }
83
return new JObject { ["mode"] = sorter.Mode.ToString(), ["drainAll"] = sorter.DrainAll,
84
["items"] = new JArray(filters.Take(128).Select(value => new JObject {
85
["itemId"] = value.ItemId.ToString(), ["allSubTypes"] = value.AllSubTypes })),
86
["total"] = filters.Count, ["truncated"] = filters.Count > 128 };
87
}
88
89
private static string FilterKey(MyInventoryItemFilter filter)
90
{
91
return filter.AllSubTypes ? filter.ItemId.TypeId.ToString() + "/*" : filter.ItemId.ToString();
92
}
93
94
private static MyDefinitionId InventoryDefinition(JToken token, bool allSubTypes)
95
{
96
if (token == null || token.Type != JTokenType.String) throw new ArgumentException("itemId/itemType must be a string.");
97
string text = (string)token;
98
if (text.Length == 0 || text.Length > 256 || text.Any(char.IsControl) || text != text.Trim())
99
throw new ArgumentException("itemId/itemType must contain 1..256 characters without surrounding whitespace or control characters.");
100
int slash = text.IndexOf('/');
101
if (slash < 0 && allSubTypes) { text += "/"; slash = text.Length - 1; }
102
if (slash <= 0 || text.IndexOf('/', slash + 1) >= 0 || !text.StartsWith("MyObjectBuilder_", StringComparison.Ordinal))
103
throw new ArgumentException("Use MyObjectBuilder_Type/Subtype; an allSubTypes filter may use MyObjectBuilder_Type alone.");
104
string subtype = text.Substring(slash + 1);
105
if (allSubTypes ? subtype.Length > 0 && subtype != "(null)" : subtype.Length == 0 || subtype == "(null)")
106
throw new ArgumentException("allSubTypes requires an empty subtype; a specific item requires a nonempty subtype.");
107
if (text.Substring(0, slash).Any(c => !(char.IsLetterOrDigit(c) || c == '_')) || subtype != subtype.Trim())
108
throw new ArgumentException("Invalid item definition syntax.");
109
MyDefinitionId definition;
110
if (!MyDefinitionId.TryParse(text, out definition) || !typeof(MyObjectBuilder_PhysicalObject).IsAssignableFrom((Type)definition.TypeId))
111
throw new ArgumentException("Item type is not a registered physical inventory item type.");
112
if (!allSubTypes && MyDefinitionManager.Static.TryGetPhysicalItemDefinition(definition) == null)
113
throw new ArgumentException("Item definition does not exist in the current world.");
114
return definition;
115
}
116
117
private static long InventoryEntityId(JObject args, string key)
118
{
119
var token = args[key];
120
if (token == null || (token.Type != JTokenType.String && token.Type != JTokenType.Integer))
121
throw new ArgumentException(key + " must be a nonzero decimal Int64 string or integer.");
122
string text = (string)token;
123
int firstDigit = text.StartsWith("-", StringComparison.Ordinal) ? 1 : 0;
124
if (text.Length <= firstDigit || text.Length > 20 || text.Skip(firstDigit).Any(c => c < '0' || c > '9'))
125
throw new ArgumentException(key + " must be a nonzero decimal Int64 string or integer.");
126
long id;
127
if (!long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out id) || id == 0)
128
throw new ArgumentException(key + " must be a nonzero decimal Int64 string or integer.");
129
return id;
130
}
131
132
private static int InventoryIndex(JObject args, string key)
133
{
134
var token = args[key];
135
if (token == null) return 0;
136
int index;
137
if (token.Type != JTokenType.Integer || !int.TryParse((string)token, NumberStyles.None, CultureInfo.InvariantCulture, out index) || index < 0 || index > 15)
138
throw new ArgumentException(key + " must be a JSON integer from 0 to 15.");
139
return index;
140
}
141
142
private static IMyInventory InventoryAt(Terminal block, int index)
143
{
144
if (!block.HasInventory || index >= block.InventoryCount) throw new ArgumentException("Inventory index does not exist on block " + Sid(block.EntityId) + ".");
145
return block.GetInventory(index) ?? throw new ArgumentException("Block inventory is unavailable.");
146
}
147
148
private static JObject InventorySummary(Terminal block, IMyInventory inventory, int index)
149
{
150
return new JObject { ["entityId"] = Sid(block.EntityId), ["gridId"] = Sid(block.CubeGrid.EntityId), ["index"] = index,
151
["massKg"] = (double)inventory.CurrentMass, ["volumeM3"] = (double)inventory.CurrentVolume,
152
["maxVolumeM3"] = (double)inventory.MaxVolume, ["freeVolumeM3"] = Math.Max(0, (double)inventory.MaxVolume - (double)inventory.CurrentVolume),
153
["isFull"] = inventory.IsFull, ["canPutItems"] = inventory.CanPutItems, ["itemCount"] = inventory.ItemCount };
154
}
155
156
private static void InventoryParameters(JObject args, params string[] names)
157
{
158
foreach (var property in args.Properties())
159
if (!names.Contains(property.Name, StringComparer.Ordinal)) throw new ArgumentException("Unknown parameter: " + property.Name);
160
}
161
}
162
}
@@ -135,6 +135,7 @@ namespace XFE.SeAgent.Plugin.Game
135
135
136
136
private static void AddTelemetry(JObject item, Terminal block, bool includeInventoryItems, bool includeScreens)
137
137
{
138
if (block is IMyConveyorSorter sorter) item["sorter"] = DescribeSorter(sorter);
138
139
if (block is IMyBatteryBlock battery) item["battery"] = new JObject { ["storedMWh"] = battery.CurrentStoredPower, ["maxStoredMWh"] = battery.MaxStoredPower, ["inputMW"] = battery.CurrentInput, ["outputMW"] = battery.CurrentOutput, ["chargeMode"] = battery.ChargeMode.ToString(), ["charging"] = battery.IsCharging };
139
140
if (block is IMyThrust thrust) item["thrust"] = new JObject { ["currentN"] = thrust.CurrentThrust, ["maximumN"] = thrust.MaxThrust, ["maxEffectiveN"] = thrust.MaxEffectiveThrust, ["overrideN"] = thrust.ThrustOverride, ["overrideRatio"] = thrust.ThrustOverridePercentage, ["gridDirection"] = Vec(thrust.GridThrustDirection), ["forceDirection"] = Vec(thrust.WorldMatrix.Backward) };
140
141
if (block is IMyGyro gyro) item["gyro"] = new JObject { ["override"] = gyro.GyroOverride, ["power"] = gyro.GyroPower, ["yaw"] = gyro.Yaw, ["pitch"] = gyro.Pitch, ["roll"] = gyro.Roll };
@@ -199,7 +200,7 @@ namespace XFE.SeAgent.Plugin.Game
199
200
inventories.Add(inv);
200
201
}
201
202
item["inventories"] = inventories;
202
item["cargoInventory"] = block is IMyCargoContainer || block is IMyShipDrill || block is IMyShipConnector;
203
item["cargoInventory"] = block is IMyCargoContainer || block is IMyShipDrill || block is IMyShipConnector || block is IMyConveyorSorter;
203
204
}
204
205
if (includeScreens)
205
206
{
@@ -1,213 +0,0 @@
1
"""Isolated integration checks for tools/prepare_debug_world.py; no real saves are accessed."""
2
3
import hashlib
4
import json
5
import os
6
from pathlib import Path
7
import subprocess
8
import sys
9
import tempfile
10
import unittest
11
import xml.etree.ElementTree as ET
12
13
14
SCRIPT = Path(__file__).resolve().parents[1] / "tools" / "prepare_debug_world.py"
15
XSI_TYPE = "{http://www.w3.org/2001/XMLSchema-instance}type"
16
17
18
class DebugWorldCopyTests(unittest.TestCase):
19
def setUp(self):
20
self.temporary = tempfile.TemporaryDirectory(prefix="xfe-world-copy-tests-")
21
self.addCleanup(self.temporary.cleanup)
22
self.root = Path(self.temporary.name).resolve()
23
self.roaming = self.root / "Roaming"
24
self.local = self.root / "Local"
25
self.account = self.roaming / "SpaceEngineers" / "Saves" / "76561198000000001"
26
self.account.mkdir(parents=True)
27
self.environment = os.environ.copy()
28
self.environment.update(
29
APPDATA=str(self.roaming),
30
LOCALAPPDATA=str(self.local),
31
PYTHONIOENCODING="utf-8",
32
)
33
self.report = self.root / "artifacts" / "preparation.json"
34
self.config = self.local / "XFE" / "SpaceEngineersAgent" / "config.json"
35
36
def world(self, name, modified=1000, ams=True, program_data=False, name_tag=False):
37
world = self.account / name
38
world.mkdir()
39
for filename in ("Sandbox.sbc", "Sandbox_config.sbc"):
40
root = ET.Element("MyObjectBuilder_Checkpoint")
41
ET.SubElement(root, "SessionName").text = name
42
settings = ET.SubElement(root, "Settings")
43
ET.SubElement(settings, "OnlineMode").text = "PUBLIC"
44
ET.SubElement(settings, "AutoSaveInMinutes").text = "5"
45
ET.SubElement(settings, "InventorySizeMultiplier").text = "10"
46
ET.SubElement(root, "Unrelated").text = "保留其他世界设置"
47
ET.ElementTree(root).write(world / filename, encoding="utf-8", xml_declaration=True)
48
sector = ET.Element("MyObjectBuilder_Sector")
49
entities = ET.SubElement(sector, "SectorObjects")
50
grid = ET.SubElement(entities, "MyObjectBuilder_EntityBase", {XSI_TYPE: "MyObjectBuilder_CubeGrid"})
51
ET.SubElement(grid, "EntityId").text = "900000000000000001"
52
ET.SubElement(grid, "DisplayName").text = "临时矿机测试网格"
53
blocks = ET.SubElement(grid, "CubeBlocks")
54
block = ET.SubElement(blocks, "MyObjectBuilder_CubeBlock", {XSI_TYPE: "MyObjectBuilder_MyProgrammableBlock"})
55
ET.SubElement(block, "EntityId").text = "900000000000000002"
56
ET.SubElement(block, "CustomName").text = "XFEAMS 测试矿机" if name_tag else "临时可编程方块"
57
code = "// XFE AMS 2.0\npublic void Main() { Echo(\"测试\"); }" if ams else "public void Main() { }"
58
ET.SubElement(block, "ProgramData" if program_data else "Program").text = code
59
ET.SubElement(block, "CustomData").text = "目标矿种=\n优先矿种=Iron,Cobalt,Nickel,Silicon"
60
ET.ElementTree(sector).write(world / "SANDBOX_0_0_0_.sbs", encoding="utf-8", xml_declaration=True)
61
(world / "Asteroid.vx2").write_bytes(b"\x00\x01fixture-voxel\xff\x80")
62
(world / "Extra").mkdir()
63
(world / "Extra" / "settings.json").write_text('{"unchanged":"metadata"}', encoding="utf-8")
64
os.utime(world / "Sandbox.sbc", (modified, modified))
65
return world
66
67
@staticmethod
68
def snapshot(world):
69
return {
70
str(path.relative_to(world)): (
71
hashlib.sha256(path.read_bytes()).hexdigest(),
72
path.stat().st_size,
73
path.stat().st_mtime_ns,
74
)
75
for path in world.rglob("*")
76
if path.is_file()
77
}
78
79
def run_copy(self, source=None, output=None, succeeds=True):
80
command = [sys.executable, str(SCRIPT), "--out", str(output or self.report)]
81
if source is not None:
82
command += ["--source", str(source)]
83
completed = subprocess.run(
84
command,
85
cwd=self.root,
86
env=self.environment,
87
text=True,
88
encoding="utf-8",
89
stdout=subprocess.PIPE,
90
stderr=subprocess.PIPE,
91
timeout=20,
92
check=False,
93
)
94
if succeeds:
95
self.assertEqual(completed.returncode, 0, completed.stderr)
96
response = json.loads(completed.stdout)
97
self.assertTrue(Path(response["debugWorld"]).is_relative_to(self.root))
98
return response, json.loads((output or self.report).read_text(encoding="utf-8"))
99
self.assertNotEqual(completed.returncode, 0, "Copy command unexpectedly succeeded")
100
return completed
101
102
def test_automatic_selection_uses_newest_ams_and_skips_debug_copies(self):
103
old = self.world("旧 AMS", modified=1000)
104
newest_ams = self.world("新 AMS", modified=2000)
105
newer_plain = self.world("更晚但没有 AMS", modified=3000, ams=False)
106
previous_debug = self.world("XFE Agent Debug AMS previous", modified=4000)
107
snapshots = {path: self.snapshot(path) for path in (old, newest_ams, newer_plain, previous_debug)}
108
response, report = self.run_copy()
109
self.assertEqual(Path(response["original"]), newest_ams)
110
self.assertEqual(Path(report["original"]), newest_ams)
111
self.assertEqual(response["programCount"], 1)
112
for path, snapshot in snapshots.items():
113
self.assertEqual(self.snapshot(path), snapshot)
114
115
def test_copy_changes_session_settings_only_in_new_world(self):
116
source = self.world("Original AMS")
117
original = self.snapshot(source)
118
response, report = self.run_copy(source)
119
target = Path(response["debugWorld"])
120
self.assertNotEqual(target, source)
121
self.assertEqual(target.parent, source.parent)
122
self.assertEqual(self.snapshot(source), original)
123
for filename in ("Sandbox.sbc", "Sandbox_config.sbc"):
124
with self.subTest(filename=filename):
125
original_xml = ET.parse(source / filename).getroot()
126
copy_xml = ET.parse(target / filename).getroot()
127
self.assertEqual(original_xml.findtext("Settings/OnlineMode"), "PUBLIC")
128
self.assertEqual(original_xml.findtext("Settings/AutoSaveInMinutes"), "5")
129
self.assertEqual(original_xml.findtext("SessionName"), source.name)
130
self.assertEqual(copy_xml.findtext("Settings/OnlineMode"), "OFFLINE")
131
self.assertEqual(copy_xml.findtext("Settings/AutoSaveInMinutes"), "0")
132
self.assertEqual(copy_xml.findtext("SessionName"), target.name)
133
self.assertEqual(copy_xml.findtext("Unrelated"), original_xml.findtext("Unrelated"))
134
self.assertEqual(copy_xml.findtext("Settings/InventorySizeMultiplier"), "10")
135
for filename in ("SANDBOX_0_0_0_.sbs", "Asteroid.vx2", "Extra/settings.json"):
136
self.assertEqual((target / filename).read_bytes(), (source / filename).read_bytes())
137
self.assertEqual(report["originalFiles"], {name: state[0] for name, state in original.items()})
138
block = report["programmableBlocks"][0]
139
self.assertEqual(block["entityId"], "900000000000000002")
140
self.assertEqual(block["gridId"], "900000000000000001")
141
self.assertIn("优先矿种=", block["customData"])
142
self.assertEqual(len(block["sourceSha256"]), 64)
143
144
def test_authorization_appends_copy_and_retains_previous_settings(self):
145
source = self.world("Authorization AMS")
146
previous = [str(self.root / "previous authorized test world")]
147
self.config.parent.mkdir(parents=True)
148
self.config.write_text(json.dumps({"allowedWorldPaths": previous, "extraSetting": {"retain": True}}), encoding="utf-8-sig")
149
response, _ = self.run_copy(source)
150
config = json.loads(self.config.read_text(encoding="utf-8"))
151
self.assertEqual(config["allowedWorldPaths"], previous + [response["debugWorld"]])
152
self.assertNotIn(str(source), config["allowedWorldPaths"])
153
self.assertEqual(config["extraSetting"], {"retain": True})
154
self.assertFalse(self.config.with_suffix(".tmp").exists())
155
156
def test_backups_are_excluded_from_copy_and_manifest_but_preserved_in_source(self):
157
source = self.world("Backups AMS")
158
for relative in ("Backup/older-world/old.sbs", "Extra/backup/old.txt"):
159
path = source / relative
160
path.parent.mkdir(parents=True, exist_ok=True)
161
path.write_bytes(b"preserve original backup")
162
original = self.snapshot(source)
163
response, report = self.run_copy(source)
164
target = Path(response["debugWorld"])
165
self.assertFalse((target / "Backup").exists())
166
self.assertFalse((target / "Extra/backup").exists())
167
self.assertTrue(all("backup" not in [part.lower() for part in Path(name).parts] for name in report["originalFiles"]))
168
self.assertEqual(self.snapshot(source), original)
169
170
def test_explicit_source_takes_precedence_over_newer_world(self):
171
selected = self.world("Explicit AMS", modified=1000)
172
self.world("Newer AMS", modified=2000)
173
response, _ = self.run_copy(selected)
174
self.assertEqual(Path(response["original"]), selected)
175
176
def test_program_data_and_named_ams_block_are_recognized(self):
177
source = self.world("ProgramData AMS", ams=False, program_data=True, name_tag=True)
178
response, report = self.run_copy(source)
179
self.assertEqual(response["programCount"], 1)
180
self.assertEqual(report["programmableBlocks"][0]["name"], "XFEAMS 测试矿机")
181
182
def test_explicit_source_without_ams_fails_without_writes(self):
183
source = self.world("No AMS", ams=False)
184
original = self.snapshot(source)
185
self.config.parent.mkdir(parents=True)
186
self.config.write_text('{"allowedWorldPaths":[],"retain":true}', encoding="utf-8")
187
old_config = self.config.read_bytes()
188
failed = self.run_copy(source, succeeds=False)
189
self.assertIn("No saved world containing AMS", failed.stderr)
190
self.assertEqual(self.snapshot(source), original)
191
self.assertEqual(self.config.read_bytes(), old_config)
192
self.assertEqual([path.name for path in self.account.iterdir()], [source.name])
193
self.assertFalse(self.report.exists())
194
195
def test_no_matching_automatic_source_fails_without_authorization(self):
196
source = self.world("Plain world", ams=False)
197
original = self.snapshot(source)
198
self.run_copy(succeeds=False)
199
self.assertFalse(self.config.exists())
200
self.assertFalse(self.report.exists())
201
self.assertEqual(self.snapshot(source), original)
202
203
def test_report_cannot_overwrite_original_world_file(self):
204
source = self.world("Protected original AMS")
205
original = self.snapshot(source)
206
self.run_copy(source, output=source / "Sandbox.sbc", succeeds=False)
207
self.assertEqual(self.snapshot(source), original)
208
self.assertFalse(self.config.exists())
209
self.assertEqual([path.name for path in self.account.iterdir()], [source.name])
210
211
212
if __name__ == "__main__":
213
unittest.main()
@@ -1,103 +0,0 @@
1
"""Copy an AMS world, authorize that copy, and record original file hashes. No game required."""
2
import argparse
3
import datetime as dt
4
import hashlib
5
import json
6
import os
7
from pathlib import Path
8
import re
9
import shutil
10
import sys
11
import xml.etree.ElementTree as ET
12
13
TYPE = "{http://www.w3.org/2001/XMLSchema-instance}type"
14
15
16
def sha(path):
17
with path.open("rb") as stream:
18
return hashlib.file_digest(stream, "sha256").hexdigest()
19
20
21
def programs(path):
22
result = []
23
# Keep an individual grid intact, then free it; never deserialize game code.
24
for _, node in ET.iterparse(path, events=("end",)):
25
if node.get(TYPE) != "MyObjectBuilder_CubeGrid":
26
continue
27
for block in node.findall("./CubeBlocks/*"):
28
if "ProgrammableBlock" not in block.get(TYPE, ""):
29
continue
30
code = block.findtext("Program") or block.findtext("ProgramData") or ""
31
name = block.findtext("CustomName") or ""
32
if "XFE AMS" not in code and "XFEAMS" not in name:
33
continue
34
result.append({"entityId": block.findtext("EntityId"), "name": name,
35
"gridId": node.findtext("EntityId"), "gridName": node.findtext("DisplayName"),
36
"sourceSha256": hashlib.sha256(code.encode("utf-8")).hexdigest(),
37
"sourceLength": len(code), "header": code.splitlines()[0] if code else "",
38
"customData": block.findtext("CustomData") or ""})
39
node.clear()
40
return result
41
42
43
def main():
44
parser = argparse.ArgumentParser(description=__doc__)
45
parser.add_argument("--source", type=Path)
46
parser.add_argument("--out", type=Path, default=Path("artifacts/world-preparation.json"))
47
args = parser.parse_args()
48
saves = Path(os.environ["APPDATA"]) / "SpaceEngineers/Saves"
49
if args.source:
50
choices = [args.source.resolve()]
51
else:
52
choices = sorted((p.parent for p in saves.glob("*/*/SANDBOX_0_0_0_.sbs")
53
if not p.parent.name.startswith("XFE Agent Debug")),
54
key=lambda p: (p / "Sandbox.sbc").stat().st_mtime, reverse=True)
55
for source in choices:
56
blocks = programs(source / "SANDBOX_0_0_0_.sbs")
57
if blocks:
58
break
59
else:
60
raise RuntimeError("No saved world containing AMS programmable blocks was found.")
61
if args.out.resolve().is_relative_to(source.resolve()):
62
raise ValueError("The report output must not be inside the original world directory.")
63
timestamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
64
target = source.parent / ("XFE Agent Debug AMS " + timestamp)
65
if target.exists():
66
raise FileExistsError(target)
67
files = [p for p in source.rglob("*") if p.is_file() and
68
not any(part.lower() == "backup" for part in p.relative_to(source).parts)]
69
originals = {str(p.relative_to(source)): sha(p) for p in files}
70
shutil.copytree(source, target, ignore=shutil.ignore_patterns("Backup", "backup"))
71
# Only these explicit session settings change, and only in the newly created copy.
72
for filename in ("Sandbox.sbc", "Sandbox_config.sbc"):
73
path = target / filename
74
if not path.exists():
75
continue
76
text = path.read_text(encoding="utf-8-sig")
77
text = re.sub(r"<SessionName>.*?</SessionName>", "<SessionName>" + target.name + "</SessionName>", text, count=1, flags=re.S)
78
text = re.sub(r"<OnlineMode>.*?</OnlineMode>", "<OnlineMode>OFFLINE</OnlineMode>", text, count=1)
79
text = re.sub(r"<AutoSaveInMinutes>.*?</AutoSaveInMinutes>", "<AutoSaveInMinutes>0</AutoSaveInMinutes>", text, count=1)
80
path.write_text(text, encoding="utf-8")
81
if any(sha(source / name) != digest for name, digest in originals.items()):
82
raise RuntimeError("Original world changed while copying; do not load this snapshot.")
83
config_path = Path(os.environ["LOCALAPPDATA"]) / "XFE/SpaceEngineersAgent/config.json"
84
config_path.parent.mkdir(parents=True, exist_ok=True)
85
config = json.loads(config_path.read_text(encoding="utf-8-sig")) if config_path.exists() else {}
86
allowed = config.setdefault("allowedWorldPaths", [])
87
if str(target) not in allowed:
88
allowed.append(str(target))
89
temp = config_path.with_suffix(".tmp")
90
temp.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
91
os.replace(temp, config_path)
92
report = {"original": str(source), "debugWorld": str(target), "created": timestamp,
93
"originalFiles": originals, "programmableBlocks": blocks,
94
"changes": ["copy session name", "offline multiplayer", "disable automatic saving"]}
95
args.out.parent.mkdir(parents=True, exist_ok=True)
96
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
97
print(json.dumps({"original": str(source), "debugWorld": str(target), "programCount": len(blocks),
98
"report": str(args.out.resolve())}, ensure_ascii=False))
99
100
101
if __name__ == "__main__":
102
sys.stdout.reconfigure(encoding="utf-8")
103
main()
@@ -1,99 +0,0 @@
1
"""Record actual AMS flight state and motion without losing case-sensitive minified field names."""
2
import argparse
3
import datetime as dt
4
import json
5
from pathlib import Path
6
import subprocess
7
import sys
8
import time
9
10
# Each mapping is checked against that exact minifier output. Never accept a
11
# new hash merely because a previous build happened to use the same names.
12
BASE = dict(state="Ԙ", flight="أ", job="ا", entry="ŏ", report="ب", progress="ؾ",
13
command="ψ", attitude="χ", clearance="φ", scanIndex="Ͼ", blocked="ϭ",
14
problem="ſ", camera="Ѕ", cameraDetail="І", obstacle="Ї")
15
MAPPINGS = {
16
"2cc105a720ecd1552791816a0edf106cfab91b501a1e8048421b7f23382d333a": BASE,
17
"b191430cf9ab7fe2b21569344ebcdc927bfa52847b84632440bdc68f3e5abc73": BASE,
18
"2aed54ad323c588852f626455df0bb61c0e6565970c173dd5ed7007d832d259f":
19
dict(state="Ԛ", flight="إ", job="ة", entry="Ő", report="ت", progress="ـ",
20
command="ω", attitude="ψ", clearance="χ", scanIndex="Ͽ", blocked="Ϯ",
21
problem="ƀ", camera="І", cameraDetail="Ї", obstacle="Ј"),
22
"1146850d83e9d165d436828c2f526c212aa9e2928abe6eb447102cc4d3e7f2e3":
23
dict(state="Ԝ", flight="ا", job="ث", entry="Ő", report="ج", progress="ق",
24
command="ύ", attitude="ό", clearance="ϋ", scanIndex="Ѓ", blocked="ϲ",
25
problem="ƅ", camera="Њ", cameraDetail="Ћ", obstacle="Ќ"),
26
}
27
# Capture extension changes no inspected declarations; checked against the new export.
28
MAPPINGS["9553e0ec92de8fc93c814852f37ff088f579c038ca8bdcf6c06b57e0f44d28ab"] = MAPPINGS[
29
"1146850d83e9d165d436828c2f526c212aa9e2928abe6eb447102cc4d3e7f2e3"]
30
STATES = {"î": "Boot", "ï": "Docked", "ð": "Servicing", "ñ": "Ready",
31
"ò": "Departing", "ó": "Transit", "ô": "Survey", "õ": "Align",
32
"ö": "Drilling", "ø": "Retreat", "ù": "Returning", "ú": "Holding",
33
"û": "DockAlign", "ü": "DockApproach", "ý": "DockRetreat",
34
"þ": "Paused", "ÿ": "Manual", "u": "Fault"}
35
36
37
def main():
38
parser = argparse.ArgumentParser(description=__doc__)
39
parser.add_argument("--block", required=True)
40
parser.add_argument("--grid", required=True)
41
parser.add_argument("--seconds", type=float, default=30)
42
parser.add_argument("--interval", type=float, default=1)
43
parser.add_argument("--out", type=Path, required=True)
44
parser.add_argument("--pipe")
45
args = parser.parse_args()
46
if not 0 < args.seconds <= 600 or not .1 <= args.interval <= 30:
47
parser.error("seconds must be 0..600 and interval must be 0.1..30")
48
cli = Path(__file__).resolve().parents[1] / "src/XFE.SeAgent.Cli/bin/Release/net10.0/xfe-se.exe"
49
50
def call(method, parameters):
51
command = [str(cli), "call", method, "--params", json.dumps(parameters)]
52
if args.pipe:
53
command += ["--pipe", args.pipe]
54
process = subprocess.run(command, capture_output=True, encoding="utf-8", timeout=35)
55
response = json.loads(process.stdout)
56
if process.returncode or "error" in response:
57
raise RuntimeError(response.get("error", process.stderr))
58
return response["result"]
59
60
program = call("pb.read", {"entityId": args.block})
61
if program["sha256"] not in MAPPINGS:
62
raise RuntimeError("Unrecognized AMS export " + program["sha256"] + "; derive a new field mapping for this source before tracing.")
63
mapping = MAPPINGS[program["sha256"]]
64
args.out.parent.mkdir(parents=True, exist_ok=True)
65
started = time.monotonic()
66
samples, previous = 0, None
67
with args.out.open("x", encoding="utf-8") as output:
68
while time.monotonic() - started < args.seconds:
69
fields = call("pb.inspect", {"entityId": args.block, "fields": ["E", "H"], "depth": 3})["fields"]
70
grid = call("grids.get", {"entityId": args.grid})
71
role = fields["H"]
72
flight = role[mapping["flight"]]
73
job = role[mapping["job"]]
74
state = role[mapping["state"]]
75
sample = {"utc": dt.datetime.now(dt.timezone.utc).isoformat(), "time": fields["E"],
76
"state": state, "stateName": STATES.get(state, state),
77
"sourceSha256": program["sha256"], "reason": role["l"], "position": grid["pose"]["position"],
78
"actualSpeed": grid["speed"], "commandSpeed": flight[mapping["command"]],
79
"clearance": flight[mapping["clearance"]], "attitudeError": flight[mapping["attitude"]],
80
"scanIndex": flight[mapping["scanIndex"]], "scanBlocked": flight[mapping["blocked"]],
81
"problem": flight[mapping["problem"]], "camera": flight[mapping["camera"]],
82
"cameraDetail": flight[mapping["cameraDetail"]], "obstacle": flight[mapping["obstacle"]],
83
"jobId": job.get("g") if job else None, "jobEntry": job.get(mapping["entry"]) if job else None,
84
"progressAge": fields["E"] - role[mapping["progress"]],
85
"lastReport": role[mapping["report"]], "integrity": grid.get("integrity"), "fields": fields}
86
output.write(json.dumps(sample, ensure_ascii=False, separators=(",", ":")) + "\n")
87
output.flush()
88
state = (sample["state"], sample["reason"])
89
if state != previous:
90
print(json.dumps({k: sample[k] for k in ("time", "state", "reason", "actualSpeed", "commandSpeed", "scanIndex")}, ensure_ascii=False), flush=True)
91
previous = state
92
samples += 1
93
time.sleep(args.interval)
94
print(json.dumps({"samples": samples, "output": str(args.out.resolve())}), flush=True)
95
96
97
if __name__ == "__main__":
98
sys.stdout.reconfigure(encoding="utf-8")
99
main()