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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

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

XFEstudio/XFEEDToolBox

fix: align during FSD cooldown and verify live navigation

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

代码差异

22 个文件 +807 -37
Modified .gitignore +1 -0
@@ -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
Modified README.md +3 -3
@@ -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、模块槽位与状态、工程蓝图/等级/原始与改造参数、寄存模块及转运摘要。
Modified XFEEDToolBox.Core/Navigation/FlightNavigator.cs +25 -8
@@ -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 冷却,同时转向。":"")}");
Modified XFEEDToolBox.Core/Navigation/NativeFlightControlDriver.cs +89 -7
@@ -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")
Modified XFEEDToolBox.Core/Navigation/NativeFrameProjector.cs +6 -1
@@ -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
Added XFEEDToolBox.Core/Navigation/NativeGameInputChannel.cs +31 -0
@@ -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 }
Modified XFEEDToolBox.Core/Navigation/NavigationRunner.cs +7 -3
@@ -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("跃迁抵达事件超时,已释放自动控制。");
Added XFEEDToolBox.Core/Navigation/TravelPreparation.cs +51 -0
@@ -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 }
Added XFEEDToolBox.Core/Plugins/RuntimeInteractionModule.cs +102 -0
@@ -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 }
Modified XFEEDToolBox.Test/AdaptiveNavigationTests.cs +35 -2
@@ -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));
Modified XFEEDToolBox.Test/NativeFrameProjectorTests.cs +10 -0
@@ -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);
Modified XFEEDToolBox.Test/NativeNavigationTests.cs +74 -1
@@ -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}";
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