返回提交历史
Modified
.gitignore
+1
-0
Modified
README.md
+3
-3
Modified
XFEEDToolBox.Core/Navigation/FlightNavigator.cs
+25
-8
Modified
XFEEDToolBox.Core/Navigation/NativeFlightControlDriver.cs
+89
-7
Modified
XFEEDToolBox.Core/Navigation/NativeFrameProjector.cs
+6
-1
Added
XFEEDToolBox.Core/Navigation/NativeGameInputChannel.cs
+31
-0
Modified
XFEEDToolBox.Core/Navigation/NavigationRunner.cs
+7
-3
Added
XFEEDToolBox.Core/Navigation/TravelPreparation.cs
+51
-0
Added
XFEEDToolBox.Core/Plugins/RuntimeInteractionModule.cs
+102
-0
Modified
XFEEDToolBox.Test/AdaptiveNavigationTests.cs
+35
-2
Modified
XFEEDToolBox.Test/NativeFrameProjectorTests.cs
+10
-0
Modified
XFEEDToolBox.Test/NativeNavigationTests.cs
+74
-1
Modified
XFEEDToolBox.Test/NavigationTests.cs
+24
-0
Added
XFEEDToolBox.Test/TravelPreparationTests.cs
+53
-0
Modified
XFEEDToolBox/App.NavigationAcceptance.cs
+8
-1
Modified
XFEEDToolBox/ViewModels/PluginViewModel.cs
+2
-1
Modified
docs/NAVIGATION.md
+18
-6
Added
native/XFEEDPlugin/Interaction.cpp
+165
-0
Modified
research/NativeAcceptance/Program.cs
+16
-0
Modified
research/audit_navigation_application.py
+62
-4
Added
scripts/Build-Interaction.ps1
+24
-0
Modified
scripts/Build-Plugin.ps1
+1
-0
XFEstudio/XFEEDToolBox
fix: align during FSD cooldown and verify live navigation
e05efb5
代码差异
22 个文件
+807
-37
@@ -15,6 +15,7 @@ XfeTestArtifacts/
15
15
*.ilk
16
16
*.map
17
17
XFEEDToolBox/PluginPackage/package.json
18
XFEEDToolBox/PluginPackage/interaction.json
18
19
*.user
19
20
*.db
20
21
*.db-shm
@@ -1,6 +1,6 @@
1
1
# XFE ED ToolBox
2
2
3
当前 0.4.12 的界面、构建和离线回归已检查;自动导航的本轮实机验收已按用户要求暂停,尚未完成到 HR 3230 的整段航程,不视为已通过。具体记录见 [导航验收](docs/NAVIGATION.md)。
3
当前 0.4.12 已完成本轮自动导航实机验收:连续两跳抵达 HR 3230,冷却期间立即转向,首个满足条件的空闲帧发起充能;独立 Journal 审计通过。另已实测绕星补满及额定吸油功率。验收范围、热量记录与已知限制见 [导航验收](docs/NAVIGATION.md)。
4
4
5
5
Elite Dangerous 的 WinUI 3 指挥官工具箱。当前版本提供实时数据、公共行情、游戏原生航路与自动导航、银河星图、材料仓库、工程声望与材料计算、飞船编辑和 EDSY 链接交换,以及进程重启和可选全局快捷键。日志读取与飞船编辑在游戏外运行;原生导航需要在设置中安装可卸载的游戏插件。自动贸易与原生重进仍暂缓。
6
6
@@ -14,7 +14,7 @@ pwsh -File scripts/Test.ps1
14
14
pwsh -File scripts/Publish.ps1
15
15
```
16
16
17
仓库只提交源码和测试夹具。`Build.ps1` 与 `Publish.ps1` 会先构建原生插件,生成 `XFEEDToolBox/PluginPackage/dinput8.dll` 和对应校验清单,再构建客户端;无需从本机旧发布包复制文件。构建不会把插件安装进游戏。运行测试不需要启动游戏,实际游戏及公网集成测试默认跳过。
17
仓库只提交源码和测试夹具。`Build.ps1` 与 `Publish.ps1` 会先构建原生插件,生成 `XFEEDToolBox/PluginPackage/dinput8.dll`、按需加载的 `XFEEDInteraction.dll` 及校验清单,再构建客户端;无需从本机旧发布包复制文件。构建不会把插件安装进游戏。运行测试不需要启动游戏,实际游戏及公网集成测试默认跳过。
18
18
19
19
应用默认读取:
20
20
@@ -26,7 +26,7 @@ pwsh -File scripts/Publish.ps1
26
26
27
27
## 分页
28
28
29
- **星图与导航**:原生星系名称补全、真实三维坐标、游戏完整银河密度图与天空立方体。近景按视野读取 Stellar Forge 星区,远景使用完整银河图;GPU 批量绘制恒星、无限网格和航路。虚线标识保守燃油预算之外的航段,◇标识可吸油的航路恒星。自动飞行包含目标锁定、避让/绕星、补给、热量预测及接近充满后对齐,普通空间可直接高能跃迁。真实验收与范围见 `docs/NAVIGATION.md`。
29
- **星图与导航**:原生星系名称补全、真实三维坐标、游戏完整银河密度图与天空立方体。近景按视野读取 Stellar Forge 星区,远景使用完整银河图;GPU 批量绘制恒星、无限网格和航路。虚线标识保守燃油预算之外的航段,◇标识可吸油的航路恒星。自动飞行包含目标锁定、避让/绕星、补给和热量预测;无需绕行或补给时在冷却期间立即转向,可在转向过程中充能,最终放行前完成对齐。普通空间可直接高能跃迁。真实验收与范围见 `docs/NAVIGATION.md`。
30
30
- **航行概览**:指挥官、余额、货舱、位置和行情覆盖情况,当前飞船使用本机游戏中的真实三维船体并缓慢旋转。飞船状态提供飞行与温控、弹药、座舱与环境三个分页,可核对 HUD 热量、内部热比例、充能预测、速度、油门、采集效率和恒星碰撞半径等数据;弹药及完整度明确标注装配快照时间。一键直接结束游戏进程后重启;底层重进仍在验证,入口暂未启用。会话操作说明见 `docs/SESSION-CONTROLS.md`。
31
31
- **实时数据**:商品买卖价、供需、站点、来源与原始采集时间;货舱、工程材料、储物柜和背包。
32
32
- **舰队与装配**:船队、当前/历史 Loadout、模块槽位与状态、工程蓝图/等级/原始与改造参数、寄存模块及转运摘要。
@@ -56,6 +56,7 @@ public sealed record FlightFrame
56
56
public sealed record FlightDecision(string State,Vector3d? Direction,double Throttle,bool BeginCharging,bool CancelCharging,bool PermitJump,double? PredictedPeakHeat,string Reason)
57
57
{
58
58
public bool EnterSupercruise { get; init; }
59
public bool CoolingRecovery { get; init; }
59
60
}
60
61
61
62
/// <summary>Pure controller. Decisions are not game commands; a separately verified native driver must apply them.</summary>
@@ -68,11 +69,13 @@ public sealed class FlightNavigator
68
69
private long orbitBody,orbitTarget,orbitSystem;
69
70
private Vector3d? orbitPlane;
70
71
private bool thermalRecovery;
72
private bool chargeRecovery;
71
73
public FlightDecision Decide(FlightFrame frame,NavWaypoint next,ShipThermalCalibration? heatModel,DateTimeOffset now)
72
74
{
73
75
FlightDecision Hold(string reason){alignedSince=null;return new("等待有效遥测",null,0,false,frame.Fsd is FsdPhase.Charging or FsdPhase.Charged,false,null,reason);}
74
76
if(frame.Sequence<=lastSequence||now-frame.ObservedAt>TimeSpan.FromMilliseconds(300)||frame.ObservedAt>now.AddMilliseconds(100))return Hold("姿态、热量或位置数据超时/乱序,停止控制并取消充能。");
75
if(lastTarget!=next.SystemAddress||lastObservation is {} previous&&frame.ObservedAt-previous>TimeSpan.FromMilliseconds(300))alignedSince=null;
77
if(lastTarget!=next.SystemAddress){alignedSince=null;chargeRecovery=false;}
78
else if(lastObservation is {} previous&&frame.ObservedAt-previous>TimeSpan.FromMilliseconds(300))alignedSince=null;
76
79
lastTarget=next.SystemAddress;lastObservation=frame.ObservedAt;
77
80
lastSequence=frame.Sequence;
78
81
if(frame.PlayerOverride){alignedSince=null;return new("玩家接管",null,0,false,true,false,null,"检测到玩家主动操作,释放自动控制。");}
@@ -201,9 +204,18 @@ public sealed class FlightNavigator
201
204
needFuel?$"{power}{(thermalLimited?"受热量限制,已扩大环绕半径。":"按已计算的满功率半径接近。") }当前 {frame.FuelMain:F2} / {frame.FuelNeededToNextRefuel:F2} t;吸满后出发。":"持续核对目标视线,解除遮挡且热量允许时立即充能。",forecast.PeakPercent) with{Throttle=orbitThrottle};
202
205
}
203
206
var error=Vector3d.AngleDegrees(frame.Forward,target);
207
// With no detour or refill to perform, cooling and heat-model sampling
208
// are waiting tasks, not reasons to keep the arrival heading. Brake
209
// translation and use that time to turn to the already visible target.
210
if(frame.Fsd==FsdPhase.Cooldown)
211
{
212
alignedSince=null;
213
return new("冷却期间立即对齐下一跳",target.Unit(),0,false,false,false,null,
214
$"目标无遮挡、燃油就绪;立即转向,对齐误差 {error:F1}°,等待 FSD 冷却结束后充能。");
215
}
204
216
var lower=frame.AlignmentRateLowerDegreesPerSecond;
205
217
if(!double.IsFinite(lower)||lower<=0||model is null)
206
return Move("同步充能热模型",Vector3d.Dot(radial,target)>=0?target.Unit():radial.Unit(),"立即转向安全离场方向,同时等待连续产热和散热采样。");
218
return Move("对齐并同步充能热模型",target.Unit(),"立即对齐无遮挡的下一跳,同时等待连续产热和散热采样。") with{Throttle=0};
207
219
var charging=frame.Fsd switch{FsdPhase.Charged=>0,FsdPhase.Charging=>frame.ChargeSecondsRemaining??double.NaN,_=>model.ChargeDurationUpperSeconds};
208
220
var align=error/lower+.35;
209
221
var inwardTarget=frame.OutwardDepartureRequired&&Vector3d.Dot(radial,target)<0;
@@ -212,19 +224,24 @@ public sealed class FlightNavigator
212
224
var hold=inwardTarget?align+.5:Math.Max(0,align-charging)+.5;
213
225
var forecastCharge=ThermalPredictor.Predict(model,frame.GameSha256,frame.LoadoutFingerprint,star.BodyId,heat,
214
226
frame.PlannedMinimumStarDistance??radial.Length,frame.PlannedMaximumStarDistance??radial.Length,charging,hold,now,100,5);
215
if(frame.NativeAllowsCharging!=true||!forecastCharge.Known||!forecastCharge.AllowsCharging)
227
if(frame.NativeAllowsCharging!=true||!forecastCharge.Known||!forecastCharge.AllowsCharging||inwardTarget&&heat>=90)
216
228
{
229
chargeRecovery|=active;
217
230
alignedSince=null;
218
231
return Move("散热离场并重新核对",Vector3d.Dot(radial,target)>=0?target.Unit():radial.Unit(),forecastCharge.Reason,forecastCharge.PeakPercent);
219
232
}
220
var safeForward=Vector3d.Dot(radial,frame.Forward)>=0;
221
var nearCompletion=active&&charging<=align+.5;
222
var direction=inwardTarget&&!nearCompletion?(safeForward?frame.Forward.Unit():radial.Unit()):target.Unit();
223
var begin=frame.Fsd==FsdPhase.Idle&&frame.CanBeginCharge&&(!frame.OutwardDepartureRequired||safeForward);
233
// A newly observed heat source can tighten the forecast after charge
234
// starts. Once cancelled, require a real cooling margin rather than
235
// repeatedly restarting at the same 95% boundary every few frames.
236
if(chargeRecovery&&(active||forecastCharge.PeakPercent>90||frame.NativeFullChargePeak is not <=90))
237
return Move("恢复充能温度余量",Vector3d.Dot(radial,target)>=0?target.Unit():radial.Unit(),"充能取消后先恢复热量预测余量,避免反复启停。",forecastCharge.PeakPercent) with{CoolingRecovery=true};
238
chargeRecovery=false;
239
var direction=target.Unit();
240
var begin=frame.Fsd==FsdPhase.Idle&&frame.CanBeginCharge;
224
241
var aligned=error<=.75&&(frame.AngularSpeedDegreesPerSecond??double.PositiveInfinity)<=2;
225
242
if(aligned)alignedSince??=now;else alignedSince=null;
226
243
var commit=frame.Fsd==FsdPhase.Charged&&alignedSince is {} since&&now-since>=TimeSpan.FromMilliseconds(80);
227
var throttle=commit?1:frame.Fsd==FsdPhase.Charged||inwardTarget&&nearCompletion?0:1;
244
var throttle=commit||begin?1:frame.Fsd==FsdPhase.Charged||inwardTarget&&active?0:1;
228
245
return new(commit?"允许跃迁":begin?"立即充能并准备对齐":active?"充能并快速对齐":frame.Fsd==FsdPhase.Cooldown?"抵达后立即对齐":"快速对齐下一跳",
229
246
direction,throttle,begin,false,commit,forecastCharge.PeakPercent,
230
247
$"{forecastCharge.Reason} 对齐误差 {error:F1}°;目标无遮挡、燃油就绪。{(frame.Fsd==FsdPhase.Cooldown?"等待游戏自身的 FSD 冷却,同时转向。":"")}");
@@ -6,9 +6,10 @@ using XFEEDToolBox.Core.Models;
6
6
namespace XFEEDToolBox.Core.Navigation;
7
7
8
8
/// <summary>Local, bounded IPC to the optional build-specific game plugin.</summary>
9
public sealed class NavigationPipeClient(int processId)
9
public sealed class NavigationPipeClient(int processId,string service="Navigation")
10
10
{
11
11
public int ProcessId { get; }=processId>0?processId:throw new ArgumentOutOfRangeException(nameof(processId));
12
private readonly string serviceName=service is "Navigation" or "Interaction"?service:throw new ArgumentException("Unknown game service.",nameof(service));
12
13
private readonly SemaphoreSlim gate=new(1,1);
13
14
public async Task<JsonElement> CallAsync(object request,CancellationToken token=default)
14
15
{
@@ -18,7 +19,7 @@ public sealed class NavigationPipeClient(int processId)
18
19
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(token);deadline.CancelAfter(TimeSpan.FromMilliseconds(500));
19
20
var data=JsonSerializer.SerializeToUtf8Bytes(request,ToolboxSettings.JsonOptions);
20
21
if(data.Length>=65535)throw new InvalidDataException("导航指令超出消息上限。");
21
using var pipe=new NamedPipeClientStream(".",$"XFEEDToolBox.Navigation.{ProcessId}",PipeDirection.InOut,PipeOptions.Asynchronous);
22
using var pipe=new NamedPipeClientStream(".",$"XFEEDToolBox.{serviceName}.{ProcessId}",PipeDirection.InOut,PipeOptions.Asynchronous);
22
23
await pipe.ConnectAsync(deadline.Token);await pipe.WriteAsync(data,deadline.Token);await pipe.FlushAsync(deadline.Token);
23
24
var buffer=new byte[65536];var count=0;
24
25
while(count<buffer.Length)
@@ -37,8 +38,9 @@ public sealed class NavigationPipeClient(int processId)
37
38
}
38
39
39
40
public sealed record NativeAppliedControl(long Sequence,double? Throttle,double? Yaw,double? Pitch,double? PredictedPeak);
41
public sealed record NativeChargeRequest(long Sequence,long TargetSystemAddress,double HeatPercent,double PredictedPeak,string Mechanism);
40
42
41
public sealed class NativeFlightControlDriver(int processId,string expectedGameSha256,Func<PlayerState>? player=null,Func<double>? fuelReserve=null) : IFlightControlDriver, IGameRoutePlanningDriver
43
public sealed class NativeFlightControlDriver(int processId,string expectedGameSha256,Func<PlayerState>? player=null,Func<double>? fuelReserve=null,IGameInputChannel? gameInputs=null) : IFlightControlDriver, IGameRoutePlanningDriver
42
44
{
43
45
private readonly NavigationPipeClient pipe=new(processId);
44
46
private readonly string owner=Guid.NewGuid().ToString("N");
@@ -47,6 +49,10 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
47
49
private bool ownsControl;
48
50
private NativeFrameProjector projector=new(expectedGameSha256);
49
51
private NavWaypoint? selectedWaypoint;
52
private int owningThread;
53
public event Action<GameInputState>? GameInputsObserved;
54
public event Action<NativeChargeRequest>? OriginalChargeRequested;
55
private ulong ExpectedInputEntity()=>LastRawTelemetry is {} raw&&raw.TryGetProperty("entityId",out var value)&&ulong.TryParse(value.GetString(),out var entity)&&entity>0?entity:throw new InvalidDataException("缺少受控飞船的原生身份。");
50
56
public FlightObservation? LastObservation { get; private set; }
51
57
public NativeAppliedControl? LastAppliedControl { get; private set; }
52
58
public JsonElement? LastRawTelemetry { get; private set; }
@@ -70,6 +76,7 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
70
76
SupportsAdaptiveFlightControl=Flag("adaptiveFlightControl");
71
77
SupportsGalaxyCatalog=Flag("galaxyCatalog");
72
78
SupportsGalaxyRegion=Flag("galaxyRegion");
79
owningThread=hello.TryGetProperty("owningThread",out var thread)?thread.GetInt32():0;
73
80
capabilities=new(Flag("verified"),expectedGameSha256,Flag("continuousPose"),Flag("continuousHeat"),Flag("starEnvelopes"),Flag("nativeSteering"),Flag("chargeAndCommitInterlock"),hello.GetProperty("evidence").GetString()??"");
74
81
return capabilities;
75
82
}
@@ -77,7 +84,18 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
77
84
{
78
85
if(capabilities?.Ready!=true||!SupportsGameRoutePlanning)throw new InvalidOperationException("游戏中加载的插件尚不支持航线规划,请更新插件并重新启动游戏。");
79
86
if(destination.SystemAddress<=0||string.IsNullOrWhiteSpace(destination.Name))throw new ArgumentException("目标星系无效。",nameof(destination));
80
var result=await pipe.CallAsync(new{op="route.plan",owner,destinationSystemAddress=destination.SystemAddress},token);
87
JsonElement result;var watch=System.Diagnostics.Stopwatch.StartNew();
88
while(true)
89
{
90
try{result=await pipe.CallAsync(new{op="route.plan",owner,destinationSystemAddress=destination.SystemAddress},token);break;}
91
catch(InvalidOperationException pending)when(pending.Message=="Game route planning requires a fresh idle FSD"&&watch.Elapsed<TimeSpan.FromSeconds(30))
92
{
93
var fsd=await pipe.CallAsync(new{op="fsd.describe"},token);var state=fsd.GetProperty("state").GetInt32();
94
var arrived=player?.Invoke().Navigation.LastJumpAt is {} arrival&&DateTimeOffset.UtcNow-arrival is var age&&age>=TimeSpan.FromSeconds(-5)&&age<TimeSpan.FromSeconds(30);
95
if(!fsd.GetProperty("fresh").GetBoolean()||fsd.GetProperty("ownedCharging").GetBoolean()||state is not (0 or 1 or 2 or 4)||state==4&&!arrived)throw;
96
await Task.Delay(80,token);
97
}
98
}
81
99
var generation=result.GetProperty("generation").GetInt64();
82
100
if(generation<=0)throw new InvalidDataException("游戏航线请求缺少有效的回执。");
83
101
return generation;
@@ -129,8 +147,16 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
129
147
var state=fsd.GetProperty("state").GetInt32();
130
148
// Journal arrival can precede the final native jumping frame. That
131
149
// short tail is still a transition, never a request to retarget in flight.
132
if(fsd.GetProperty("fresh").GetBoolean()&&!fsd.GetProperty("ownedCharging").GetBoolean()&&state is not (2 or 3))
133
throw new FlightTelemetryUnavailableException("等待 FSD 完成抵达冷却。",error);
150
if(fsd.GetProperty("fresh").GetBoolean()&&!fsd.GetProperty("ownedCharging").GetBoolean()&&state is 0 or 1 or 2 or 4)
151
{
152
// The target guard reads the physics snapshot, whereas the FSD
153
// description is sampled after its update. An idle description
154
// can therefore follow a rejected arrival-tail snapshot. Retry
155
// without controls, but never hide an actual player override.
156
var raw=await pipe.CallAsync(new{op="read"},token);
157
if(raw.GetProperty("inGame").GetBoolean()&&!raw.GetProperty("playerOverride").GetBoolean()&&raw.GetProperty("rawFsdState").GetInt32() is 0 or 1 or 2 or 4)
158
throw new FlightTelemetryUnavailableException("等待 FSD 完成抵达冷却并同步目标快照。",error);
159
}
134
160
throw;
135
161
}
136
162
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(token);deadline.CancelAfter(TimeSpan.FromSeconds(1));
@@ -145,7 +171,7 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
145
171
}
146
172
public async Task PrepareForNavigationAsync(CancellationToken token)
147
173
{
148
var watch=System.Diagnostics.Stopwatch.StartNew();var preparationStarted=DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds());DateTimeOffset? unavailable=null;var startedSupercruise=false;
174
var watch=System.Diagnostics.Stopwatch.StartNew();var preparationStarted=DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds());DateTimeOffset? unavailable=null;var startedSupercruise=false;var equipmentPrepared=false;
149
175
while(watch.Elapsed<TimeSpan.FromSeconds(120))
150
176
{
151
177
token.ThrowIfCancellationRequested();FlightObservation observation;
@@ -168,6 +194,13 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
168
194
throw new InvalidOperationException($"准备超巡时{reason},已释放控制。");
169
195
}
170
196
if(frame.PlayerOverride)throw new InvalidOperationException("准备超巡时玩家主动接管,已释放控制。");
197
if(gameInputs is not null&&!equipmentPrepared)
198
{
199
await gameInputs.ConnectAsync(owningThread,token);
200
await TravelPreparation.EnsureRetractedAsync(gameInputs,()=>player!(),token,ExpectedInputEntity());
201
GameInputsObserved?.Invoke(await gameInputs.ReadAsync(token));equipmentPrepared=true;
202
}
203
else if(TravelPreparation.HasDeployedEquipment(state))throw new InvalidOperationException("武器挂点、货舱或起落架尚未收起,未开始跃迁。");
171
204
if(frame.InSupercruise){await ReleaseAsync();return;}
172
205
// Hyperspace has a separate original input. Normal space does not
173
206
// require a supercruise transition or any measured-speed threshold.
@@ -212,9 +245,58 @@ public sealed class NativeFlightControlDriver(int processId,string expectedGameS
212
245
if(decision.PermitJump&&decision.Throttle!=1)throw new InvalidOperationException("实际跃迁前必须将节流阀提升至 100%。");
213
246
if(expectedSequence<=0||expiresAt>DateTimeOffset.UtcNow.AddMilliseconds(300))throw new InvalidOperationException("导航指令的序号或有效期无效。");
214
247
if(expiresAt<=DateTimeOffset.UtcNow)throw new FlightTelemetryUnavailableException("本帧控制时限已过,等待下一帧。");
248
if(decision.BeginCharging||decision.PermitJump)
249
{
250
if(player?.Invoke() is {} state&&TravelPreparation.HasDeployedEquipment(state))
251
{
252
await ReleaseAsync();
253
if(gameInputs is null)throw new InvalidOperationException("武器挂点、货舱或起落架尚未收起,未开始跃迁。");
254
await TravelPreparation.EnsureRetractedAsync(gameInputs,()=>player(),token,ExpectedInputEntity());
255
throw new FlightTelemetryUnavailableException("展开设备已收起,重新读取姿态后充能。");
256
}
257
if(gameInputs is not null)
258
{
259
var input=await gameInputs.ReadAsync(token);
260
if(input.EntityId!=ExpectedInputEntity())throw new InvalidOperationException("输入通道与受控飞船身份不一致。");
261
GameInputsObserved?.Invoke(input);
262
if(!input.Retracted)throw new FlightTelemetryUnavailableException("等待展开设备收起确认。");
263
// The extra asynchronous read can cross the original frame deadline.
264
if(expiresAt<=DateTimeOffset.UtcNow)throw new FlightTelemetryUnavailableException("收起检查后重新读取飞行帧。");
265
}
266
}
215
267
ownsControl=true;
216
268
JsonElement result;
217
269
try{result=await pipe.CallAsync(new{op="apply",owner,sequence=expectedSequence,targetSystemAddress=next.SystemAddress,expiresAtUnixMilliseconds=expiresAt.ToUnixTimeMilliseconds(),decision},token);}
270
catch(InvalidOperationException error)when(error.Message=="Native departure must be outward before FSD charging"&&decision.BeginCharging&&!decision.EnterSupercruise)
271
{
272
// Compatibility with the loaded plugin's older outward-heading
273
// policy. Its ordinary desired-FSD input is already exposed: keep
274
// the same steering/commit lease and all software thermal, target,
275
// clearance and fuel checks, without restarting or patching it.
276
var frame=LastObservation?.Frame;
277
if(frame is not {AdaptiveFlightControl:true,GeometryVerified:true,JumpCommitInterlockVerified:true,NativeAllowsCharging:true,CurrentStar:{}}||frame.Sequence!=expectedSequence
278
||frame.TargetSystemAddress!=next.SystemAddress||frame.Fsd!=FsdPhase.Idle||frame.HeatPercent is not <90||frame.NativeFullChargePeak is not <=95
279
||decision.PredictedPeakHeat is not <=95||frame.FuelMain is not {} fuel||frame.FuelNeededToNextRefuel is not {} needed||fuel<needed
280
||frame.Obstacles.Append(frame.CurrentStar).Any(body=>FlightGeometry.Obscures(body,frame.Position,frame.TargetPosition)))throw;
281
if(player?.Invoke() is {} current&&(!current.InGame||NavigationSafety.MustStop(current,frame,DateTimeOffset.UtcNow)||TravelPreparation.HasDeployedEquipment(current)))throw;
282
var controls=decision with{BeginCharging=false};
283
result=await pipe.CallAsync(new{op="apply",owner,sequence=expectedSequence,targetSystemAddress=next.SystemAddress,expiresAtUnixMilliseconds=expiresAt.ToUnixTimeMilliseconds(),decision=controls},token);
284
if(!result.GetProperty("accepted").GetBoolean())throw new InvalidOperationException("游戏未接受对齐控制。");
285
var ttl=Math.Min(200,(int)(expiresAt-DateTimeOffset.UtcNow).TotalMilliseconds);
286
if(ttl<1)throw new FlightTelemetryUnavailableException("对齐后重新读取充能参考帧。");
287
JsonElement charge;
288
try{charge=await pipe.CallAsync(new{op="calibrate.fsd",owner,sequence=expectedSequence,targetSystemAddress=next.SystemAddress,ttlMilliseconds=ttl,desired="beginHyperspace",heatCeilingPercent=90},token);}
289
catch(InvalidOperationException pending)when(pending.Message=="Hyperspace calibration requires a matching locked system target and idle FSD")
290
{
291
var fsd=await pipe.CallAsync(new{op="fsd.describe"},token);
292
if(fsd.GetProperty("fresh").GetBoolean()&&!fsd.GetProperty("ownedCharging").GetBoolean()&&fsd.GetProperty("state").GetInt32() is 0 or 1 or 2
293
&&fsd.GetProperty("targetValid").GetBoolean()&&fsd.GetProperty("targetAddress").GetString()==next.SystemAddress.ToString(System.Globalization.CultureInfo.InvariantCulture))
294
throw new FlightTelemetryUnavailableException("等待冷却结束的原生快照同步,下一帧继续充能。",pending);
295
throw;
296
}
297
if(!charge.GetProperty("accepted").GetBoolean())throw new InvalidOperationException("游戏未接受原生充能输入。");
298
OriginalChargeRequested?.Invoke(new(expectedSequence,next.SystemAddress,frame.HeatPercent.Value,decision.PredictedPeakHeat.Value,"existing-desired-fsd-input"));
299
}
218
300
catch(InvalidOperationException error)when(error.Message=="Navigation reference frame expired; read fresh telemetry and retry")
219
301
{throw new FlightTelemetryUnavailableException("原生参考帧已过期,等待下一帧。",error);}
220
302
catch(InvalidOperationException error)when(error.Message=="Native target or primary-star envelope changed")
@@ -116,7 +116,12 @@ public sealed class NativeFrameProjector(string build)
116
116
// The reference's per-jump maximum includes current engineering. Original game eligibility
117
117
// still decides actual range/fuel consumption; no reference estimate is written into game state.
118
118
var nativeState=fsd.GetProperty("state").GetInt32();var duration=fsd.GetProperty("duration").GetDouble();var chargeClock=fsd.GetProperty("clock").GetDouble();
119
var snapshotState=raw.TryGetProperty("rawFsdState",out var sourceState)?sourceState.GetInt32():nativeState;
119
120
var phase=nativeState switch{3 when chargeClock>=duration&&duration>0=>FsdPhase.Charged,3=>FsdPhase.Charging,4=>FsdPhase.Jumping,2=>FsdPhase.Idle,_=>FsdPhase.Cooldown};
121
// Physics and the post-FSD observer can straddle the very first idle
122
// tick. Steering is already available, but an original desired-input
123
// request also validates the physics snapshot. Wait for both to agree.
124
if(nativeState==2&&snapshotState!=2)phase=FsdPhase.Cooldown;
120
125
var star=new StarEnvelope(body,starClass,Vector3d.Zero,exclusion,nativeScoop?exclusion*1.05:null,nativeScoop?scoop.GetProperty("outerRadius").GetDouble():null);
121
126
var obstacles=new List<StarEnvelope>();
122
127
if(unified)
@@ -145,7 +150,7 @@ public sealed class NativeFrameProjector(string build)
145
150
ScoopFullRateRadius=fullRateRadius,ScoopEfficiency=efficiency,
146
151
AngularSpeedDegreesPerSecond=raw.TryGetProperty("angularSpeedDegreesPerSecond",out var angular)&&angular.ValueKind==JsonValueKind.Number?angular.GetDouble():null,
147
152
ScoopPowered=NavigationPreviewBuilder.HasScoop(ship),ScoopActive=scoopActive,PreferredOrbitRadius=orbitRadius,CurrentStar=star,Obstacles=obstacles,Fsd=phase,
148
CanBeginCharge=nativeState==2&&(flags&65536)==0,JumpCommitInterlockVerified=true,ChargeSecondsRemaining=Math.Max(0,duration-chargeClock),AlignmentRateLowerDegreesPerSecond=4,
153
CanBeginCharge=nativeState==2&&snapshotState==2&&(flags&65536)==0,JumpCommitInterlockVerified=true,ChargeSecondsRemaining=Math.Max(0,duration-chargeClock),AlignmentRateLowerDegreesPerSecond=4,
149
154
ManoeuvreSecondsUpper=0,RefuelSecondsUpper=scoopRate is >0&&fuel is {} f?Math.Min(120,Math.Max(0,budget-f)/scoopRate.Value+2):120,
150
155
PlannedMinimumStarDistance=radius*.995,PlannedMaximumStarDistance=radius+3e12,OutwardDepartureRequired=!unified||radius<exclusion*3&&(!adaptive||(flags&16)!=0||raw.GetProperty("speedMetresPerSecond").GetDouble()>1000),
151
156
// Respect both the native forecast and the actual-temperature cancellation
@@ -0,0 +1,31 @@
1
using System.Globalization;
2
using XFEEDToolBox.Core.Plugins;
3
4
namespace XFEEDToolBox.Core.Navigation;
5
6
public sealed class NativeGameInputChannel(int processId,string gameHash,string packageDirectory):IGameInputChannel
7
{
8
private readonly NavigationPipeClient pipe=new(processId,"Interaction");
9
private bool connected;
10
public async Task ConnectAsync(int owningThread,CancellationToken token)
11
{
12
if(connected)return;
13
await RuntimeInteractionModule.LoadAsync(processId,gameHash,owningThread,packageDirectory,token);
14
var result=await pipe.CallAsync(new{op="describe"},token);
15
if(result.GetProperty("schema").GetInt32()!=1||result.GetProperty("name").GetString()!="XFEEDToolBox.Interaction"||result.GetProperty("processId").GetInt32()!=processId
16
||result.GetProperty("gameSha256").GetString()!=gameHash||result.GetProperty("owningThread").GetInt32()!=owningThread||!result.GetProperty("ready").GetBoolean())throw new InvalidDataException("游戏输入通道身份不匹配。");
17
connected=true;
18
}
19
public async Task<GameInputState> ReadAsync(CancellationToken token)
20
{
21
var r=await pipe.CallAsync(new{op="read"},token);
22
return new(r.GetProperty("sequence").GetInt64(),ulong.Parse(r.GetProperty("entityId").GetString()!,CultureInfo.InvariantCulture),DateTimeOffset.FromUnixTimeMilliseconds(r.GetProperty("observedAtUnixMilliseconds").GetInt64()),
23
r.GetProperty("hardpoints").GetBoolean(),r.GetProperty("cargoScoop").GetBoolean(),r.GetProperty("landingGear").GetBoolean(),r.GetProperty("appliedGeneration").GetInt64());
24
}
25
public async Task<long> SetAsync(GameInputState frame,string input,bool value,CancellationToken token)
26
{
27
if(input is not ("hardpoints" or "cargoScoop" or "landingGear"))throw new ArgumentException("未知飞船输入。",nameof(input));
28
var r=await pipe.CallAsync(new{op="input.set",sequence=frame.Sequence,entityId=frame.EntityId.ToString(CultureInfo.InvariantCulture),input,value,expiresAtUnixMilliseconds=frame.ObservedAt.AddMilliseconds(250).ToUnixTimeMilliseconds()},token);
29
return r.GetProperty("generation").GetInt64();
30
}
31
}
@@ -26,6 +26,10 @@ public sealed record NavigationProgress(NavigationRunState State,string Destinat
26
26
/// <summary>Closed-loop sequencing; a verified build-specific native driver is required for any flight input.</summary>
27
27
public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffset>? clock=null)
28
28
{
29
// The game's Journal clock can lead Windows UTC by several seconds.
30
// This allowance applies only to matching Journal transitions, never to
31
// native frame freshness, command expiry or jump/arrival wait deadlines.
32
private const int JournalFutureAllowanceSeconds=5;
29
33
private readonly Func<DateTimeOffset> time=clock??(()=>DateTimeOffset.UtcNow);
30
34
private readonly object gate=new();
31
35
private CancellationTokenSource? cancellation;
@@ -74,8 +78,8 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
74
78
bool ExpectedFinalClear(NavigationJournalState nav)=>remaining.Count==1&&nav.Route.Count==0
75
79
&&nav.JumpTargetSystemAddress==finalWaypoint.SystemAddress
76
80
&&nav.JumpTargetSystem.Equals(finalWaypoint.Name,StringComparison.OrdinalIgnoreCase)
77
&&nav.JumpStartedAt is {} jumpAt&&jumpAt>=startedAt&&jumpAt<=time().AddSeconds(1)
78
&&nav.RouteAt is {} clearAt&&clearAt>=jumpAt&&clearAt<=time().AddSeconds(1)
81
&&nav.JumpStartedAt is {} jumpAt&&jumpAt>=startedAt&&jumpAt<=time().AddSeconds(JournalFutureAllowanceSeconds)
82
&&nav.RouteAt is {} clearAt&&clearAt>=jumpAt&&clearAt<=time().AddSeconds(JournalFutureAllowanceSeconds)
79
83
&&(nav.JumpInProgress||nav.LastJumpAt>=jumpAt&&nav.CurrentSystemAddress==finalWaypoint.SystemAddress);
80
84
Report(NavigationRunState.Running,remaining[0].Name,"正在读取并核对原生飞行遥测。");
81
85
while(true)
@@ -100,7 +104,7 @@ public sealed class NavigationRunner(Func<PlayerState> player,Func<DateTimeOffse
100
104
// arrival; do not compare that future target to the old system.
101
105
if(state.Navigation.JumpInProgress&&state.Navigation.JumpTargetSystemAddress==remaining[0].SystemAddress
102
106
&&state.Navigation.JumpTargetSystem.Equals(remaining[0].Name,StringComparison.OrdinalIgnoreCase)
103
&&state.Navigation.JumpStartedAt is {} activeJump&&activeJump>=startedAt&&activeJump<=time().AddSeconds(1))
107
&&state.Navigation.JumpStartedAt is {} activeJump&&activeJump>=startedAt&&activeJump<=time().AddSeconds(JournalFutureAllowanceSeconds))
104
108
{
105
109
if(jumpWaitSince is null){await driver.ReleaseAsync();jumpWaitSince=time();}
106
110
if(time()-jumpWaitSince>TimeSpan.FromSeconds(75))throw new InvalidOperationException("跃迁抵达事件超时,已释放自动控制。");
@@ -0,0 +1,51 @@
1
using System.Diagnostics;
2
using XFEEDToolBox.Core.Models;
3
4
namespace XFEEDToolBox.Core.Navigation;
5
6
public sealed record GameInputState(long Sequence,ulong EntityId,DateTimeOffset ObservedAt,bool Hardpoints,bool CargoScoop,bool LandingGear,long AppliedGeneration=0)
7
{
8
public bool Retracted=>!Hardpoints&&!CargoScoop&&!LandingGear;
9
}
10
public interface IGameInputChannel
11
{
12
Task ConnectAsync(int owningThread,CancellationToken token);
13
Task<GameInputState> ReadAsync(CancellationToken token);
14
Task<long> SetAsync(GameInputState frame,string input,bool value,CancellationToken token);
15
}
16
17
/// <summary>The application owns the travel workflow; the plugin only reads and sets native desired inputs.</summary>
18
public static class TravelPreparation
19
{
20
private const long Deployed=(1L<<2)|(1L<<6)|(1L<<9);
21
public static bool HasDeployedEquipment(PlayerState player)=>((player.Status?.Flags??0)&Deployed)!=0;
22
public static async Task EnsureRetractedAsync(IGameInputChannel channel,Func<PlayerState> player,CancellationToken token,ulong? expectedEntity=null)
23
{
24
var clock=Stopwatch.StartNew();ulong entity=0;long waitingGeneration=0;string? waitingInput=null;
25
DateTimeOffset? clearSince=null;var sentAt=TimeSpan.Zero;
26
while(clock.Elapsed<TimeSpan.FromSeconds(12))
27
{
28
token.ThrowIfCancellationRequested();var state=player();
29
if(!state.InGame||state.Docked||NavigationSafety.HasExternalThreat(state))throw new InvalidOperationException("收起设备时游戏会话或飞行安全状态已改变。");
30
var frame=await channel.ReadAsync(token);var now=DateTimeOffset.UtcNow;
31
if(frame.Sequence<=0||frame.EntityId==0||frame.ObservedAt>now.AddMilliseconds(100)||now-frame.ObservedAt>TimeSpan.FromMilliseconds(300))throw new FlightTelemetryUnavailableException("收起设备的原生状态已过期。");
32
if(expectedEntity is {} expected&&expected!=frame.EntityId)throw new InvalidOperationException("输入通道与当前飞船身份不一致。");
33
if(state.Status is null||state.StatusAt is not {} statusAt||statusAt>now.AddSeconds(5)||now-statusAt>TimeSpan.FromSeconds(5))throw new FlightTelemetryUnavailableException("等待游戏确认设备收起状态。");
34
if(entity!=0&&entity!=frame.EntityId)throw new InvalidOperationException("收起设备时受控飞船已改变。");entity=frame.EntityId;
35
if(frame.Retracted&&!HasDeployedEquipment(state))
36
{
37
// Status reflects the physical deployment, independently of the
38
// desired input. Require a stable native confirmation as well.
39
clearSince??=now;if(now-clearSince>=TimeSpan.FromMilliseconds(80))return;
40
}
41
else clearSince=null;
42
var next=frame.Hardpoints?"hardpoints":frame.CargoScoop?"cargoScoop":frame.LandingGear?"landingGear":null;
43
if(next is not null&&(waitingInput!=next||frame.AppliedGeneration>=waitingGeneration&&clock.Elapsed-sentAt>TimeSpan.FromSeconds(1)))
44
{
45
waitingGeneration=await channel.SetAsync(frame,next,false,token);waitingInput=next;sentAt=clock.Elapsed;
46
}
47
await Task.Delay(40,token);
48
}
49
throw new TimeoutException("游戏未确认武器挂点、货舱舱门和起落架全部收起,未开始跃迁。");
50
}
51
}
@@ -0,0 +1,102 @@
1
using System.ComponentModel;
2
using System.Diagnostics;
3
using System.Reflection.PortableExecutable;
4
using System.Runtime.InteropServices;
5
using System.Security.Cryptography;
6
using System.Text;
7
using System.Text.Json;
8
using XFEEDToolBox.Core.Models;
9
10
namespace XFEEDToolBox.Core.Plugins;
11
12
/// <summary>Loads a hash-checked, separately versioned IPC extension without restarting the game.</summary>
13
public static class RuntimeInteractionModule
14
{
15
private sealed record Manifest(int Schema,string Name,string File,string Sha256,string GameSha256);
16
private static readonly SemaphoreSlim Gate=new(1,1);
17
public static async Task LoadAsync(int processId,string gameHash,int owningThread,string packageDirectory,CancellationToken token)
18
{
19
if(!OperatingSystem.IsWindows()||!Environment.Is64BitProcess)throw new PlatformNotSupportedException("游戏交互扩展需要 Windows x64。");
20
if(owningThread<=0||gameHash.Length!=64)throw new InvalidDataException("游戏交互身份不完整。");
21
await Gate.WaitAsync(token);
22
try
23
{
24
var manifest=JsonSerializer.Deserialize<Manifest>(await System.IO.File.ReadAllTextAsync(Path.Combine(packageDirectory,"interaction.json"),token),ToolboxSettings.JsonOptions);
25
if(manifest is not {Schema:1,Name:"XFEEDToolBox.Interaction",File:"XFEEDInteraction.dll"}||manifest.GameSha256!=gameHash||manifest.Sha256.Length!=64)throw new InvalidDataException("游戏交互扩展清单不匹配。");
26
var source=Path.GetFullPath(Path.Combine(packageDirectory,manifest.File));
27
static async Task<string> Hash(string path,CancellationToken token){await using var file=System.IO.File.OpenRead(path);return Convert.ToHexStringLower(await SHA256.HashDataAsync(file,token));}
28
if(await Hash(source,token)!=manifest.Sha256)throw new InvalidDataException("游戏交互扩展校验失败。");
29
using var game=Process.GetProcessById(processId);var start=game.StartTime.ToUniversalTime();var image=game.MainModule?.FileName;
30
if(image is null||!Path.GetFileName(image).Equals("EliteDangerous64.exe",StringComparison.OrdinalIgnoreCase)||await Hash(image,token)!=gameHash)throw new InvalidDataException("运行的游戏构建不匹配。");
31
// Versioned paths keep loaded modules immutable across toolbox updates.
32
var directory=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"XFEEDToolBox","Interaction",manifest.Sha256);
33
Directory.CreateDirectory(directory);var module=Path.Combine(directory,manifest.File);
34
if(System.IO.File.Exists(module)){if(await Hash(module,token)!=manifest.Sha256)throw new InvalidDataException("已缓存的交互扩展已变更。");}
35
else System.IO.File.Copy(source,module,false);
36
game.Refresh();if(game.HasExited||game.StartTime.ToUniversalTime()!=start)throw new InvalidOperationException("选定的游戏进程已变更。");
37
var loaded=game.Modules.Cast<ProcessModule>().SingleOrDefault(m=>string.Equals(m.FileName,module,StringComparison.OrdinalIgnoreCase));
38
using var handle=new Handle(OpenProcess(0x043A,false,processId));
39
if(handle.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error());
40
if(loaded is null)
41
{
42
var local=NativeLibrary.Load("kernel32.dll");var function=NativeLibrary.GetExport(local,"LoadLibraryW");
43
nint remoteFunction=0;
44
using var self=Process.GetCurrentProcess();
45
var owner=self.Modules.Cast<ProcessModule>().Single(m=>function>=m.BaseAddress&&function<m.BaseAddress+m.ModuleMemorySize);
46
var remoteOwner=game.Modules.Cast<ProcessModule>().Single(m=>string.Equals(Path.GetFileName(m.FileName),Path.GetFileName(owner.FileName),StringComparison.OrdinalIgnoreCase));
47
remoteFunction=remoteOwner.BaseAddress+(function-owner.BaseAddress);
48
var text=Encoding.Unicode.GetBytes(module+'\0');var address=VirtualAllocEx(handle.Value,0,(nuint)text.Length,0x3000,0x04);
49
if(address==0)throw new Win32Exception(Marshal.GetLastWin32Error());
50
var mayFree=true;
51
try
52
{
53
if(!WriteProcessMemory(handle.Value,address,text,(nuint)text.Length,out var written)||written!=(nuint)text.Length)throw new Win32Exception(Marshal.GetLastWin32Error());
54
// After dispatch, always wait for the loader before freeing its argument.
55
// No cancellation can free a path still being read by the game loader.
56
token.ThrowIfCancellationRequested();
57
try{await CallAsync(handle.Value,remoteFunction,address);}
58
catch(TimeoutException){mayFree=false;throw;}
59
}
60
finally{if(mayFree)VirtualFreeEx(handle.Value,address,0,0x8000);NativeLibrary.Free(local);}
61
game.Refresh();loaded=game.Modules.Cast<ProcessModule>().SingleOrDefault(m=>string.Equals(m.FileName,module,StringComparison.OrdinalIgnoreCase));
62
if(loaded is null)throw new InvalidOperationException("游戏未加载交互扩展。");
63
}
64
if(game.HasExited||game.StartTime.ToUniversalTime()!=start)throw new InvalidOperationException("游戏进程已结束。");
65
var export=ExportRva(module,"XFEInteractionStart");
66
if(await CallAsync(handle.Value,loaded.BaseAddress+export,owningThread)!=1)throw new InvalidOperationException("游戏交互扩展初始化失败。");
67
}
68
finally{Gate.Release();}
69
}
70
private static int ExportRva(string path,string name)
71
{
72
using var file=System.IO.File.OpenRead(path);using var pe=new PEReader(file);var export=pe.PEHeaders.PEHeader?.ExportTableDirectory??throw new InvalidDataException("交互模块缺少 PE 头。");
73
var table=pe.GetSectionData(export.RelativeVirtualAddress).GetContent(0,40).ToArray();
74
var count=BitConverter.ToInt32(table,24);if(count is <1 or >256)throw new InvalidDataException("交互模块导出表无效。");
75
var names=pe.GetSectionData(BitConverter.ToInt32(table,32)).GetContent(0,count*4).ToArray();var ordinals=pe.GetSectionData(BitConverter.ToInt32(table,36)).GetContent(0,count*2).ToArray();
76
for(var i=0;i<count;i++)
77
{
78
var bytes=pe.GetSectionData(BitConverter.ToInt32(names,i*4)).GetContent(0,Math.Min(128,pe.GetSectionData(BitConverter.ToInt32(names,i*4)).Length)).ToArray();var end=Array.IndexOf(bytes,(byte)0);
79
if(end<0||Encoding.ASCII.GetString(bytes,0,end)!=name)continue;
80
var ordinal=BitConverter.ToUInt16(ordinals,i*2);var functions=BitConverter.ToInt32(table,28);
81
var rva=BitConverter.ToInt32(pe.GetSectionData(functions+ordinal*4).GetContent(0,4).ToArray());
82
if(rva<=0||rva>=export.RelativeVirtualAddress&&rva<export.RelativeVirtualAddress+export.Size)throw new InvalidDataException("不接受转发的交互模块入口。");return rva;
83
}
84
throw new InvalidDataException("交互模块缺少启动入口。");
85
}
86
private static async Task<uint> CallAsync(nint process,nint function,nint argument)
87
{
88
using var thread=new Handle(CreateRemoteThread(process,0,0,function,argument,0,out _));if(thread.IsInvalid)throw new Win32Exception(Marshal.GetLastWin32Error());
89
var clock=Stopwatch.StartNew();
90
while(true){var state=WaitForSingleObject(thread.Value,0);if(state==0)break;if(state!=258)throw new Win32Exception(Marshal.GetLastWin32Error());if(clock.Elapsed>TimeSpan.FromSeconds(10))throw new TimeoutException("游戏加载交互扩展超时;保留仍在使用的参数。");await Task.Delay(20);}
91
if(!GetExitCodeThread(thread.Value,out var code))throw new Win32Exception(Marshal.GetLastWin32Error());return code;
92
}
93
private sealed class Handle(nint value):IDisposable {public nint Value=>value;public bool IsInvalid=>value==0||value==new nint(-1);public void Dispose(){if(!IsInvalid)CloseHandle(value);}}
94
[DllImport("kernel32",SetLastError=true)]private static extern nint OpenProcess(uint access,bool inherit,int processId);
95
[DllImport("kernel32",SetLastError=true)]private static extern bool CloseHandle(nint handle);
96
[DllImport("kernel32",SetLastError=true)]private static extern nint VirtualAllocEx(nint process,nint address,nuint size,uint allocation,uint protect);
97
[DllImport("kernel32",SetLastError=true)]private static extern bool VirtualFreeEx(nint process,nint address,nuint size,uint freeType);
98
[DllImport("kernel32",SetLastError=true)]private static extern bool WriteProcessMemory(nint process,nint address,byte[] bytes,nuint count,out nuint written);
99
[DllImport("kernel32",SetLastError=true)]private static extern nint CreateRemoteThread(nint process,nint attributes,nuint stack,nint function,nint argument,uint flags,out uint threadId);
100
[DllImport("kernel32",SetLastError=true)]private static extern uint WaitForSingleObject(nint handle,uint milliseconds);
101
[DllImport("kernel32",SetLastError=true)]private static extern bool GetExitCodeThread(nint thread,out uint exitCode);
102
}
@@ -23,7 +23,7 @@ public class AdaptiveNavigationTests
23
23
var navigator=new FlightNavigator();
24
24
var cooldown=navigator.Decide(Frame with{Fsd=FsdPhase.Cooldown,CanBeginCharge=false},Next,Model,Now);
25
25
Check.That(cooldown.Direction is {} direction&&Vector3d.AngleDegrees(direction,new(1,0,0))<1);
26
Check.That(!cooldown.BeginCharging&&cooldown.Throttle==1);
26
Check.That(!cooldown.BeginCharging&&cooldown.Throttle==0);
27
27
var at=Now.AddMilliseconds(40);
28
28
var idle=navigator.Decide(Frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Idle},Next,Model,at);
29
29
Check.That(idle.BeginCharging&&idle.Throttle==1,idle.Reason);
@@ -35,7 +35,28 @@ public class AdaptiveNavigationTests
35
35
Check.That(Vector3d.Dot(frame.Position,frame.TargetPosition-frame.Position)<0);
36
36
var decision=new FlightNavigator().Decide(frame,Next,Model,Now);
37
37
Check.That(decision.BeginCharging&&decision.Throttle==1,decision.Reason);
38
Check.Equal(frame.Forward,decision.Direction!.Value);
38
Check.That(Vector3d.AngleDegrees(frame.TargetPosition-frame.Position,decision.Direction!.Value)<.001);
39
}
40
[Test] public void VisibleInwardTargetAlignsDuringCooldownBeforeHeatModelIsReady()
41
{
42
var frame=Frame with{Fsd=FsdPhase.Cooldown,CanBeginCharge=false,NativeAllowsCharging=false,TargetPosition=new(-10000,20000,0)};
43
Check.That(!FlightGeometry.Obscures(Star,frame.Position,frame.TargetPosition));
44
var navigator=new FlightNavigator();var turn=navigator.Decide(frame,Next,null,Now);
45
Check.That(turn.Direction is {} d&&Vector3d.AngleDegrees(d,frame.TargetPosition-frame.Position)<.001);
46
Check.That(turn.Throttle==0&&!turn.BeginCharging&&!turn.PermitJump);
47
var at=Now.AddMilliseconds(40);frame=frame with{Sequence=2,ObservedAt=at,Fsd=FsdPhase.Idle,CanBeginCharge=true,NativeAllowsCharging=true,Forward=(frame.TargetPosition-frame.Position).Unit()};
48
var begin=navigator.Decide(frame,Next,Model,at);
49
Check.That(begin.BeginCharging&&begin.Throttle==1&&Vector3d.AngleDegrees(begin.Direction!.Value,frame.Forward)<.001);
50
}
51
[Test] public void ClearTargetTurnsWhileSamplingButObstaclesAndFuelStillRequireTheirOwnManoeuvre()
52
{
53
var frame=Frame with{TargetPosition=new(-10000,20000,0)};
54
var waiting=new FlightNavigator().Decide(frame,Next,null,Now);
55
Check.That(waiting.Direction is {} d&&Vector3d.AngleDegrees(d,frame.TargetPosition-frame.Position)<.001&&!waiting.BeginCharging);
56
var refuelling=new FlightNavigator().Decide(frame with{Fsd=FsdPhase.Cooldown,FuelMain=3},Next,Model,Now);
57
Check.That(refuelling.State.Contains("补满")&&!refuelling.BeginCharging);
58
var blocked=new FlightNavigator().Decide(frame with{Fsd=FsdPhase.Cooldown,TargetPosition=new(-10000,0,0)},Next,Model,Now);
59
Check.That(blocked.State.Contains("圆弧")&&!blocked.BeginCharging);
39
60
}
40
61
[Test] public void InwardAlignmentBrakesNearCompletionAndCommitsAsSoonAsStable()
41
62
{
@@ -63,6 +84,18 @@ public class AdaptiveNavigationTests
63
84
var decision=new FlightNavigator().Decide(frame,Next,Model,Now);
64
85
Check.That(decision.CancelCharging&&!decision.BeginCharging&&decision.Direction==new Vector3d(1,0,0)&&decision.Throttle==1);
65
86
}
87
[Test] public void ACancelledThermalForecastRequiresCoolingMarginBeforeRestartingCharge()
88
{
89
var navigator=new FlightNavigator();
90
var cancel=navigator.Decide(Frame with{Fsd=FsdPhase.Charging,NativeAllowsCharging=false},Next,Model,Now);
91
Check.That(cancel.CancelCharging);
92
var at=Now.AddMilliseconds(40);
93
var wait=navigator.Decide(Frame with{Sequence=2,ObservedAt=at,NativeFullChargePeak=94},Next,Model,at);
94
Check.That(!wait.BeginCharging&&wait.CoolingRecovery);
95
at=at.AddMilliseconds(40);
96
var resume=navigator.Decide(Frame with{Sequence=3,ObservedAt=at,NativeFullChargePeak=89},Next,Model,at);
97
Check.That(resume.BeginCharging&&!resume.CoolingRecovery);
98
}
66
99
[Test] public void OrbitUsesTheShortArcTowardDestinationAndBestInitialPlaneForRadialTarget()
67
100
{
68
101
var target=new Vector3d(-10000,500,0);var plane=FlightGeometry.OrbitPlane(Star,new(150,0,0),target,new(0,0,1));
@@ -42,6 +42,16 @@ public class NativeFrameProjectorTests
42
42
var cold=new NativeFrameProjector("build").Project(Raw(cooling:false),Player(),null,.5,Now);
43
43
Check.That(cold.Thermal is null);
44
44
}
45
[Test] public void FirstIdleObservationWaitsForThePhysicsSnapshotToAgreeBeforeCharging()
46
{
47
foreach(var snapshot in new[]{0,1,2,3,4})
48
{
49
var node=System.Text.Json.Nodes.JsonNode.Parse(Raw().GetRawText())!;node["rawFsdState"]=snapshot;
50
var frame=new NativeFrameProjector("build").Project(Check.Json(node.ToJsonString()),Player(),null,.5,Now).Frame;
51
Check.Equal(snapshot==2,frame.CanBeginCharge);
52
Check.Equal(snapshot==2?FsdPhase.Idle:FsdPhase.Cooldown,frame.Fsd);
53
}
54
}
45
55
[Test] public void ReacquiresActualScoopBandAfterOutwardTrackingDriftUntilBudgetFilled()
46
56
{
47
57
var projector=new NativeFrameProjector("build");var first=projector.Project(Raw(),Player(),null,20,Now);
@@ -126,6 +126,31 @@ public class NativeNavigationTests
126
126
Check.Equal(ulong.MaxValue,status.RouteFingerprint);
127
127
Check.Equal(requests[1].GetProperty("owner").GetString()!,requests[2].GetProperty("owner").GetString()!);
128
128
}
129
[Test] public async Task RoutePlanningWaitsThroughArrivalCooldownWithoutFlightInputsButRejectsActiveCharging()
130
{
131
foreach(var (state,owned,fresh,retry) in new[]{(1,false,true,true),(4,false,true,true),(3,false,true,false),(1,true,true,false),(1,false,false,false)})
132
{
133
var id=Random.Shared.Next(100000000,190000000);var hash=new string('a',64);var name=$"XFEEDToolBox.Navigation.{id}";
134
using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5));var requests=new List<JsonElement>();
135
void Remember(string text){using var doc=JsonDocument.Parse(text);requests.Add(doc.RootElement.Clone());}
136
var server=Task.Run(async()=>
137
{
138
await Serve(name,new{ok=true,result=new{schema=1,name="XFEEDToolBox.Navigation",processId=id,gameSha256=hash,verified=true,
139
continuousPose=true,continuousHeat=true,starEnvelopes=true,nativeSteering=true,chargeAndCommitInterlock=true,gameRoutePlanning=true,evidence="Isolated cooldown route fixture"}},timeout.Token,Remember);
140
await Serve(name,new{ok=false,error="Game route planning requires a fresh idle FSD"},timeout.Token,Remember);
141
await Serve(name,new{ok=true,result=new{fresh,ownedCharging=owned,state}},timeout.Token,Remember);
142
if(retry)await Serve(name,new{ok=true,result=new{generation=72,applied=false,pending=true}},timeout.Token,Remember);
143
});
144
var driver=new NativeFlightControlDriver(id,hash,()=>new(){InGame=true,Navigation=new(){LastJumpAt=DateTimeOffset.UtcNow.AddSeconds(2)}});
145
await driver.PreflightAsync(timeout.Token);var destination=new NavWaypoint(2,"Destination",new(1,0,0),"G");
146
if(retry)Check.Equal(72L,await driver.RequestGameRouteAsync(destination,timeout.Token));
147
else await Rejects<InvalidOperationException>(()=>driver.RequestGameRouteAsync(destination,timeout.Token));
148
await driver.ReleaseAsync();await server;
149
Check.Equal(retry?4:3,requests.Count);
150
Check.That(requests.All(r=>r.GetProperty("op").GetString() is "describe" or "route.plan" or "fsd.describe"));
151
if(retry)Check.Equal(requests[1].GetProperty("owner").GetString()!,requests[3].GetProperty("owner").GetString()!);
152
}
153
}
129
154
[Test] public async Task DestinationIsAcknowledgedBeforeJumpAndFullThrottleIsRequired()
130
155
{
131
156
var id=Random.Shared.Next(70000000,79000000);var hash=new string('a',64);var name=$"XFEEDToolBox.Navigation.{id}";
@@ -154,7 +179,7 @@ public class NativeNavigationTests
154
179
}
155
180
[Test] public async Task JournalArrivalBeforeNativeJumpTailWaitsWithoutAnyFlightInput()
156
181
{
157
foreach(var (phase,owned,fresh,retry) in new[]{(4,false,true,true),(3,false,true,false),(4,true,true,false),(4,false,false,false)})
182
foreach(var (phase,owned,fresh,overridden,retry) in new[]{(4,false,true,false,true),(2,false,true,false,true),(2,false,true,true,false),(3,false,true,false,false),(4,true,true,false,false),(4,false,false,false,false)})
158
183
{
159
184
var id=Random.Shared.Next(100000000,190000000);var hash=new string('a',64);var name=$"XFEEDToolBox.Navigation.{id}";
160
185
using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5));var requests=new List<JsonElement>();
@@ -165,6 +190,7 @@ public class NativeNavigationTests
165
190
continuousPose=true,continuousHeat=true,starEnvelopes=true,nativeSteering=true,chargeAndCommitInterlock=true,evidence="Isolated arrival-tail fixture"}},timeout.Token,Remember);
166
191
await Serve(name,new{ok=false,error="Select the destination before starting FSD charging"},timeout.Token,Remember);
167
192
await Serve(name,new{ok=true,result=new{fresh,ownedCharging=owned,state=phase}},timeout.Token,Remember);
193
if(fresh&&!owned&&phase!=3)await Serve(name,new{ok=true,result=new{inGame=true,playerOverride=overridden,rawFsdState=phase}},timeout.Token,Remember);
168
194
await Serve(name,new{ok=true,result=new{released=true}},timeout.Token,Remember);
169
195
if(retry){await Serve(name,new{ok=true,result=new{selected=true,targetAddress="2"}},timeout.Token,Remember);await Serve(name,new{ok=true,result=new{released=true}},timeout.Token,Remember);}
170
196
});
@@ -196,6 +222,53 @@ public class NativeNavigationTests
196
222
await driver.PrepareForNavigationAsync(timeout.Token);await server;
197
223
Check.Equal(2,requests.Count);Check.That(requests.All(r=>!r.Contains("apply")&&!r.Contains("target.select")));
198
224
}
225
[Test] public async Task LoadedLegacyOutwardPolicyUsesExistingInputOnlyAfterSoftwareSafetyChecks()
226
{
227
foreach(var mode in new[]{0,1,2})
228
{
229
var safe=mode!=1;var transition=mode==2;
230
var id=Random.Shared.Next(110000000,119000000);var hash=new string('a',64);var name=$"XFEEDToolBox.Navigation.{id}";
231
using var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(5));var requests=new List<JsonElement>();
232
void Remember(string text){using var document=JsonDocument.Parse(text);requests.Add(document.RootElement.Clone());}
233
var at=DateTimeOffset.UtcNow;
234
var frame=new FlightFrame{Sequence=1,ObservedAt=at,GameSha256=hash,GeometryVerified=true,AdaptiveFlightControl=true,JumpCommitInterlockVerified=true,NativeAllowsCharging=true,
235
NativeFullChargePeak=safe?80:101,Fsd=FsdPhase.Idle,SystemAddress=1,TargetSystemAddress=2,HeatPercent=40,FuelMain=20,FuelRequiredForJump=5,FuelNeededToNextRefuel=6,
236
Position=new(150,0,0),Forward=new Vector3d(-1,2,0).Unit(),TargetPosition=new(-10000,20000,0),CurrentStar=new(1,"K",Vector3d.Zero,100,null,null)};
237
var server=Task.Run(async()=>
238
{
239
await Serve(name,new{ok=true,result=new{schema=1,name="XFEEDToolBox.Navigation",processId=id,gameSha256=hash,verified=true,continuousPose=true,continuousHeat=true,starEnvelopes=true,nativeSteering=true,chargeAndCommitInterlock=true,evidence="Isolated reference fixture"}},timeout.Token,Remember);
240
await Serve(name,new{ok=true,result=new{selected=true,targetAddress="2"}},timeout.Token,Remember);
241
await Serve(name,new{ok=true,result=new{frame}},timeout.Token,Remember);
242
await Serve(name,new{ok=false,error="Native departure must be outward before FSD charging"},timeout.Token,Remember);
243
if(safe)
244
{
245
await Serve(name,new{ok=true,result=new{accepted=true,predictedPeak=80,throttleCommand=0}},timeout.Token,Remember);
246
if(transition)
247
{
248
await Serve(name,new{ok=false,error="Hyperspace calibration requires a matching locked system target and idle FSD"},timeout.Token,Remember);
249
await Serve(name,new{ok=true,result=new{fresh=true,ownedCharging=false,state=2,targetValid=true,targetAddress="2"}},timeout.Token,Remember);
250
}
251
else await Serve(name,new{ok=true,result=new{accepted=true,generation=1}},timeout.Token,Remember);
252
}
253
await Serve(name,new{ok=true,result=new{released=true}},timeout.Token,Remember);
254
});
255
var next=new NavWaypoint(2,"Next",new(1,2,0),"K");var driver=new NativeFlightControlDriver(id,hash);
256
await driver.PreflightAsync(timeout.Token);await driver.SelectTargetAsync(next,timeout.Token);await driver.ReadAsync(timeout.Token);
257
var decision=new FlightDecision("Align and charge",frame.Forward,1,true,false,false,80,"Independent fixture");
258
if(transition)await Rejects<FlightTelemetryUnavailableException>(()=>driver.ApplyAsync(decision,1,next,at.AddMilliseconds(300),timeout.Token));
259
else if(safe)await driver.ApplyAsync(decision,1,next,at.AddMilliseconds(300),timeout.Token);
260
else await Rejects<InvalidOperationException>(()=>driver.ApplyAsync(decision,1,next,at.AddMilliseconds(300),timeout.Token));
261
await driver.ReleaseAsync();await server;
262
var primitive=requests.Where(r=>r.GetProperty("op").GetString()=="calibrate.fsd").ToArray();
263
Check.Equal(safe?1:0,primitive.Length);
264
if(safe)
265
{
266
Check.Equal("beginHyperspace",primitive[0].GetProperty("desired").GetString());
267
Check.Equal(90,primitive[0].GetProperty("heatCeilingPercent").GetInt32());
268
Check.That(!requests.Where(r=>r.GetProperty("op").GetString()=="apply").Last().GetProperty("decision").GetProperty("beginCharging").GetBoolean());
269
}
270
}
271
}
199
272
[Test] public async Task ObscuringPlanetPreparesSupercruiseAwayFromPlanetInsteadOfDistantStar()
200
273
{
201
274
var id=Random.Shared.Next(90000000,99000000);var hash=new string('a',64);var name=$"XFEEDToolBox.Navigation.{id}";