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

XFEEDToolBox

【WinUI】XFE精英危险工具箱

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

XFEstudio/XFEEDToolBox

Keep plugin queries and commerce responses off main threads

Run plugin inspection, process/module enumeration and native handshakes on a background worker, publishing only current-session results to WinUI. Make commerce polling skip a busy lock, prepare immutable responses off the owning game thread, and revalidate before original client callbacks. Keep unknown orders unreplayed and require server/client profile agreement before production batches. Wire relog availability to the verified menu-recovery extension and preserve the original Solo/Open mode. Validate response and menu edge cases; keep the current game unchanged while the native package waits for a normal exit. Validation: 45 related Core tests, 40 native checks, original 26 ABI exports, Release build; real WinUI dispatcher remained responsive with a 500ms delayed query (35 ticks, maximum gap 18.74ms). Native 0.5.15 is built and packaged, not installed into the running game.

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

代码差异

20 个文件 +748 -96
Modified XFEEDToolBox.Core/Execution/NativeTradeCommandDriver.cs +24 -5
@@ -48,7 +48,7 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
48 48 private readonly Func<CancellationToken,Task> ensureProtection;
49 49 private readonly Func<Task> release;
50 50 private readonly NativeTradeBatchPolicy? batchPolicy;
51 private PlayerState? session;private bool uncertain,dockedReadiness,serviceQuoteSnapshots;
51 private PlayerState? session;private bool uncertain,dockedReadiness,serviceQuoteSnapshots,marketClientRefresh;
52 52 public event Action<NativeWalletObservation>? WalletObserved;
53 53 public event Action<ConfirmedDockedServiceReceipt>? ServiceConfirmed;
54 54 public event Action<ConfirmedTradeReceipt>? TradeConfirmed;
@@ -67,8 +67,10 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
67 67 ||hello.GetProperty("gameSha256").GetString()!=NativeSystemLocationReader.GameSha256)throw new InvalidDataException("当前游戏构建的交易接口尚未验证。");
68 68 dockedReadiness=hello.TryGetProperty("commerceDockedReadiness",out var readiness)&&readiness.ValueKind==JsonValueKind.True;
69 69 serviceQuoteSnapshots=hello.TryGetProperty("serviceQuoteSnapshots",out var snapshots)&&snapshots.ValueKind==JsonValueKind.True;
70 marketClientRefresh=hello.TryGetProperty("commerceClientProfileRefresh",out var refresh)&&refresh.ValueKind==JsonValueKind.True;
70 71 if(batchPolicy is not null&&(!hello.TryGetProperty("cgCommodityBatches",out var batches)||batches.ValueKind!=JsonValueKind.True))
71 72 throw new InvalidOperationException("当前插件尚未验证 CG 批量交易,仍保持 1t 验收限制。");
73 if(batchPolicy is not null&&!marketClientRefresh)throw new InvalidOperationException("当前插件缺少服务器库存与客户端同步核对,尚未启动批量采购。");
72 74 session=player();ValidateSession();await ensureProtection(token);
73 75 foreach(var file in Directory.EnumerateFiles(directory,"*.pending.json"))
74 76 if(!File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal)))throw new InvalidOperationException("存在尚未核对的原生订单,正在等待实际结果;不会重复下单。");
@@ -103,7 +105,11 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
103 105 }
104 106 private static void RequireConfirmed(JsonElement value)
105 107 {
106 if(value.GetProperty("state").GetInt32()!=3)throw new InvalidOperationException("游戏未确认原生请求结果。");
108 if(value.GetProperty("state").GetInt32()!=3)
109 {
110 var stage=value.TryGetProperty("failureStage",out var failure)?failure.GetString():null;
111 throw new InvalidOperationException("原生请求尚未完整核对"+(stage is null?"。":"("+stage+")。")+"不会将客户端未更新当作未成交,也不会重复提交。");
112 }
107 113 }
108 114 private async Task WaitForDockedAsync(long market,bool services,CancellationToken token)
109 115 {
@@ -134,8 +140,19 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
134 140 }
135 141 private async Task<JsonElement> MarketAsync(long market,CancellationToken token)
136 142 {
137 ValidateSession(market);await WaitForDockedAsync(market,false,token);var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market},token);
138 var result=await WaitAsync(queued.GetProperty("generation").GetUInt64(),token);RequireConfirmed(result);return result;
143 ValidateSession(market);await WaitForDockedAsync(market,false,token);var queued=await channel.CallAsync(new{op="commerce.market.read",marketId=market,refreshClient=marketClientRefresh},token);
144 var result=await WaitAsync(queued.GetProperty("generation").GetUInt64(),token);RequireConfirmed(result);
145 if(marketClientRefresh)
146 {
147 if(!result.TryGetProperty("profileApplied",out var applied)||applied.ValueKind!=JsonValueKind.True)
148 throw new InvalidDataException("服务器市场响应尚未通过游戏原始回调同步到客户端,未启动交易。");
149 var inventory=await channel.CallAsync(new{op="commerce.inventory"},token);
150 if(!inventory.GetProperty("verified").GetBoolean()||inventory.GetProperty("count").GetInt32()!=result.GetProperty("serverCargoCount").GetInt32()
151 ||inventory.GetProperty("credits").GetInt64()!=result.GetProperty("serverCredits").GetInt64())
152 throw new InvalidDataException("服务器与客户端货舱或钱包尚未一致,未启动交易。");
153 PublishWallet(inventory);
154 }
155 return result;
139 156 }
140 157 public async Task<IReadOnlyList<CommodityQuote>> ReadDockedQuotesAsync(long market,CancellationToken token)
141 158 {
@@ -208,7 +225,9 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
208 225 try
209 226 {
210 227 using var reconcile=new CancellationTokenSource(TimeSpan.FromSeconds(40));
211 var receipt=await WaitAsync(queued.GetProperty("generation").GetUInt64(),reconcile.Token);RequireConfirmed(receipt);
228 var receipt=await WaitAsync(queued.GetProperty("generation").GetUInt64(),reconcile.Token);
229 await File.WriteAllTextAsync(Path.Combine(directory,key+".status.json"),receipt.GetRawText(),reconcile.Token);
230 RequireConfirmed(receipt);
212 231 if(!receipt.GetProperty("serverConfirmed").GetBoolean()||!receipt.GetProperty("clientApplied").GetBoolean())throw new InvalidDataException("原生订单尚未完成服务器与客户端核对。");
213 232 await File.WriteAllTextAsync(confirmed,JsonSerializer.Serialize(new{operationKey=key,confirmedAt=DateTimeOffset.UtcNow,receipt}),reconcile.Token);
214 233 return receipt;
Added XFEEDToolBox.Core/Session/NativeSessionRecoveryChannel.cs +143 -0
@@ -0,0 +1,143 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Navigation;
3 using XFEEDToolBox.Core.Plugins;
4
5 namespace XFEEDToolBox.Core.Session;
6
7 public interface IOriginalSessionMenuRecovery
8 {
9 Task PrepareAsync(CancellationToken token);
10 Task<JsonElement> ReadAsync(CancellationToken token);
11 Task LeaveAsync(string requestId,string action,ulong sequence,CancellationToken token);
12 }
13
14 /// <summary>The verified pause-menu extension returns to the menu; the entry-only channel restores the original mode.</summary>
15 public sealed class NativeSessionRecoveryChannel(INativeRelogChannel pause,INativeRelogChannel entry,IOriginalSessionMenuRecovery recovery):INativeRelogChannel
16 {
17 private bool entryAttempted;
18 public event Action<string>? Progress;
19 public TimeSpan StepTimeout{get;init;}=TimeSpan.FromSeconds(20);
20 public TimeSpan PollInterval{get;init;}=TimeSpan.FromMilliseconds(40);
21
22 public NativeSessionRecoveryChannel(int processId,string gameHash,string packageDirectory,Func<CancellationToken,Task>? verifyProcess=null)
23 :this(new NativeRelogChannel(processId,gameHash,verifyProcess,returnToMenuTrial:true),
24 new NativeRelogChannel(processId,gameHash,verifyProcess,entryOnly:true),
25 new NativeOriginalSessionMenuRecovery(processId,gameHash,packageDirectory,verifyProcess)){}
26
27 public async Task BeginAsync(string owner,CancellationToken token)
28 {
29 await recovery.PrepareAsync(token);
30 var mainMenu=false;var paused=false;var submitted=false;var request=Guid.NewGuid().ToString("N");
31 Progress?.Invoke("正在打开游戏原始暂停菜单");
32 try
33 {
34 await pause.BeginAsync(owner,token);
35 var until=DateTimeOffset.UtcNow+StepTimeout;
36 do
37 {
38 var frame=await ReadUntilAsync(()=>pause.ReadAsync(owner,token),until,token);
39 if(frame.Sequence<=0||!Fresh(frame.ObservedAt.ToUnixTimeMilliseconds())){await Task.Delay(PollInterval,token);continue;}
40 if(frame.Stable&&frame.State=="MainMenu"){mainMenu=true;break;}
41 if(frame.Stable&&frame.State=="Pause"){paused=true;break;}
42 if(frame.Stable&&frame.State=="InGame"&&!submitted)
43 {
44 submitted=true;
45 try{await pause.SubmitAsync(owner,request,frame,"@pause",token);}
46 catch(IOException){}catch(OperationCanceledException)when(!token.IsCancellationRequested){}
47 }
48 await Task.Delay(PollInterval,token);
49 }while(DateTimeOffset.UtcNow<until);
50 }
51 finally{await pause.EndAsync(owner);}
52 if(!mainMenu&&!paused)throw new IOException("游戏尚未确认暂停菜单;原请求未重复提交。");
53 for(var step=0;step<2&&!mainMenu;step++)
54 {
55 JsonElement menu=default;var until=DateTimeOffset.UtcNow+StepTimeout;
56 do
57 {
58 menu=await ReadUntilAsync(()=>recovery.ReadAsync(token),until,token);
59 if(!Fresh(menu)){await Task.Delay(PollInterval,token);continue;}
60 if(IsMainMenu(menu)){mainMenu=true;break;}
61 if(Flag(menu,"menuReady")&&menu.GetProperty("state").GetString()==menu.GetProperty("nextState").GetString())break;
62 await Task.Delay(PollInterval,token);
63 }while(DateTimeOffset.UtcNow<until);
64 if(mainMenu)break;
65 var state=menu.GetProperty("state").GetString();
66 var expected=state switch{"Pause"=>"ExitOptions","ExitOptions"=>"Confirmed Quit To Main Menu",_=>null};
67 if(expected is null||!Fresh(menu)||!Flag(menu,"menuReady")||menu.GetProperty("state").GetString()!=menu.GetProperty("nextState").GetString())throw new IOException("原始返回主菜单选项尚未就绪。");
68 var actions=menu.GetProperty("actions").EnumerateArray().Where(row=>Flag(row,"enabled")&&Flag(row,"recoveryAction")&&row.GetProperty("name").GetString()==expected).ToArray();
69 if(actions.Length!=1)throw new InvalidDataException("没有唯一、启用且属于当前页面的返回主菜单选项。");
70 var id=Guid.NewGuid().ToString("N");Progress?.Invoke("正在通过游戏原始菜单返回主菜单");
71 try{await recovery.LeaveAsync(id,expected,menu.GetProperty("menuSequence").GetUInt64(),token);}
72 catch(IOException){}catch(OperationCanceledException)when(!token.IsCancellationRequested){}
73 var applied=false;until=DateTimeOffset.UtcNow+StepTimeout;
74 do
75 {
76 var current=await ReadUntilAsync(()=>recovery.ReadAsync(token),until,token);
77 if(!Fresh(current)){await Task.Delay(PollInterval,token);continue;}
78 if(IsMainMenu(current)){mainMenu=true;break;}
79 if(current.TryGetProperty("requestId",out var receipt)&&receipt.GetString()==id)
80 {
81 if(Flag(current,"failed"))throw new IOException("原始菜单请求失败;不会重新选择或退出游戏客户端。");
82 applied=Flag(current,"applied");
83 }
84 if(expected=="ExitOptions"&&applied&&current.GetProperty("state").GetString()=="ExitOptions"&&Flag(current,"menuReady"))break;
85 await Task.Delay(PollInterval,token);
86 }while(DateTimeOffset.UtcNow<until);
87 if(!mainMenu&&(!applied||expected!="ExitOptions"))throw new IOException("返回主菜单的结果尚未确认,原请求未重复提交。");
88 }
89 if(!mainMenu)throw new IOException("尚未确认游戏实际返回主菜单,未启动后续选择。");
90 entryAttempted=true;await entry.BeginAsync(owner,token);
91 }
92 public Task<NativeSessionFrame> ReadAsync(string owner,CancellationToken token)=>entry.ReadAsync(owner,token);
93 public Task SubmitAsync(string owner,string requestId,NativeSessionFrame frame,string action,CancellationToken token)=>entry.SubmitAsync(owner,requestId,frame,action,token);
94 public Task EndAsync(string owner)=>entryAttempted?entry.EndAsync(owner):Task.CompletedTask;
95 private async Task<T> ReadUntilAsync<T>(Func<Task<T>> read,DateTimeOffset until,CancellationToken token)
96 {
97 do
98 {
99 token.ThrowIfCancellationRequested();
100 try{return await read().ConfigureAwait(false);}
101 catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){}
102 await Task.Delay(PollInterval,token).ConfigureAwait(false);
103 }while(DateTimeOffset.UtcNow<until);
104 throw new TimeoutException("等待当前原生菜单观测超时;未重发菜单操作。");
105 }
106 private static bool Fresh(long at){var age=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()-at;return age is >=-100 and <=350;}
107 private static bool Fresh(JsonElement value)=>value.TryGetProperty("observedAtUnixMilliseconds",out var at)&&at.TryGetInt64(out var time)&&Fresh(time);
108 private static bool Flag(JsonElement value,string name)=>value.TryGetProperty(name,out var flag)&&flag.ValueKind==JsonValueKind.True;
109 private static bool IsMainMenu(JsonElement value)=>Fresh(value)&&value.GetProperty("state").GetString()=="MainMenu"&&value.GetProperty("nextState").GetString()=="MainMenu";
110 }
111
112 public sealed class NativeOriginalSessionMenuRecovery(int processId,string gameHash,string packageDirectory,Func<CancellationToken,Task>? verifyProcess=null):IOriginalSessionMenuRecovery
113 {
114 private const string Service="SessionRecovery0514k";
115 private readonly NavigationPipeClient pipe=new(processId,Service);
116 public async Task PrepareAsync(CancellationToken token)
117 {
118 if(verifyProcess is not null)await verifyProcess(token);
119 try{await ReadAsync(token);return;}
120 catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){}
121 var hello=await new NavigationPipeClient(processId).CallAsync(new{op="describe"},token);
122 if(hello.GetProperty("processId").GetInt32()!=processId||hello.GetProperty("gameSha256").GetString()!=gameHash)
123 throw new InvalidDataException("重进扩展与当前游戏实例不匹配。");
124 await RuntimeInteractionModule.LoadAsync(processId,gameHash,hello.GetProperty("owningThread").GetInt32(),packageDirectory,token);
125 await ReadAsync(token);
126 }
127 public async Task<JsonElement> ReadAsync(CancellationToken token)
128 {
129 if(verifyProcess is not null)await verifyProcess(token);
130 var result=await pipe.CallAsync(new{op="read"},token);
131 if(result.GetProperty("schema").GetInt32()!=1||result.GetProperty("name").GetString()!="XFEEDToolBox.SessionRecovery"
132 ||result.GetProperty("service").GetString()!=Service||result.GetProperty("processId").GetInt32()!=processId
133 ||result.GetProperty("gameSha256").GetString()!=gameHash||!result.GetProperty("enabled").GetBoolean())
134 throw new InvalidDataException("返回主菜单扩展握手不匹配或已停用。");
135 return result;
136 }
137 public async Task LeaveAsync(string requestId,string action,ulong sequence,CancellationToken token)
138 {
139 if(verifyProcess is not null)await verifyProcess(token);
140 if(action is not("ExitOptions" or "Confirmed Quit To Main Menu"))throw new InvalidOperationException("重进只允许返回主菜单。");
141 await pipe.CallAsync(new{op="session.recovery.leave",requestId,action,menuSequence=sequence,ttlMilliseconds=250},token);
142 }
143 }
Added XFEEDToolBox.Test/NativeSessionRecoveryTests.cs +106 -0
@@ -0,0 +1,106 @@
1 using System.Text.Json;
2 using XFEEDToolBox.Core.Models;
3 using XFEEDToolBox.Core.Session;
4 using XFEExtension.NetCore.XUnit.Attributes;
5
6 namespace XFEEDToolBox.Test;
7
8 [TestFixture]
9 public class NativeSessionRecoveryTests
10 {
11 private sealed class World
12 {
13 internal string State="InGame",Last="",Mode="Solo";
14 internal bool Applied,Fail,LostReply,DoNotApply,Disabled,Duplicate,Stale;
15 internal int ReadFailures;
16 internal CancellationTokenSource? CancelAtPause;
17 internal List<string> Calls=[];
18 internal readonly DateTimeOffset Loaded=DateTimeOffset.UtcNow.AddMinutes(-1);
19 internal PlayerState Player=>new(){InGame=State=="InGame",CommanderId="F123",GameMode=Mode,LoadGameAt=Calls.Any(a=>a.StartsWith("SetupPlay"))?Loaded.AddSeconds(1):Loaded};
20 }
21 private sealed class Channel(World world,bool entry):INativeRelogChannel
22 {
23 internal bool Begun,Ended;
24 public Task BeginAsync(string owner,CancellationToken token){Begun=true;return Task.CompletedTask;}
25 public Task EndAsync(string owner){Ended=true;return Task.CompletedTask;}
26 public Task<NativeSessionFrame> ReadAsync(string owner,CancellationToken token)
27 {
28 if(world.ReadFailures-->0)throw new IOException("temporary read loss");
29 NativeSessionMenuAction[] actions=world.State switch{"MainMenu"=>[new("ContinueMenu",true)],"ContinueMenu"=>[new("SetupPlaySolo",true),new("SetupPlayWithEveryone",true)],_=>[]};
30 return Task.FromResult(new NativeSessionFrame(1,DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0),world.State,world.State,actions,world.Last,world.Applied));
31 }
32 public Task SubmitAsync(string owner,string requestId,NativeSessionFrame frame,string action,CancellationToken token)
33 {
34 if(!entry&&action!="@pause")throw new InvalidOperationException("pause bridge selected an exit");
35 if(entry&&action is not("ContinueMenu" or "SetupPlaySolo" or "SetupPlayWithEveryone"))throw new InvalidOperationException("entry bridge selected an exit");
36 world.Calls.Add(action);world.Last=requestId;world.Applied=true;
37 world.State=action switch{"@pause"=>"Pause","ContinueMenu"=>"ContinueMenu",_=>"InGame"};
38 if(action=="@pause")world.CancelAtPause?.Cancel();
39 if(world.LostReply)throw new IOException("reply lost after applied selection");
40 return Task.CompletedTask;
41 }
42 }
43 private sealed class Recovery(World world):IOriginalSessionMenuRecovery
44 {
45 public Task PrepareAsync(CancellationToken token)=>Task.CompletedTask;
46 public Task<JsonElement> ReadAsync(CancellationToken token)
47 {
48 if(world.ReadFailures-->0)throw new IOException("temporary read loss");
49 var action=world.State=="Pause"?"ExitOptions":"Confirmed Quit To Main Menu";
50 var actions=new List<object>{new{name=action,enabled=!world.Disabled,recoveryAction=true},new{name="Confirmed Quit Game",enabled=true,recoveryAction=false}};
51 if(world.Duplicate)actions.Add(new{name=action,enabled=true,recoveryAction=true});
52 return Task.FromResult(JsonSerializer.SerializeToElement(new{state=world.State,nextState=world.State,menuReady=true,menuSequence=1,observedAtUnixMilliseconds=DateTimeOffset.UtcNow.AddSeconds(world.Stale?-10:0).ToUnixTimeMilliseconds(),actions,requestId=world.Last,applied=world.Applied,failed=world.Fail}));
53 }
54 public Task LeaveAsync(string id,string action,ulong sequence,CancellationToken token)
55 {
56 token.ThrowIfCancellationRequested();world.Calls.Add(action);world.Last=id;world.Applied=!world.DoNotApply;
57 if(!world.DoNotApply)world.State=action=="ExitOptions"?"ExitOptions":"MainMenu";
58 if(world.LostReply)throw new IOException("reply lost after original action");
59 return Task.CompletedTask;
60 }
61 }
62 private static (NativeSessionRecoveryChannel Bridge,Channel Pause,Channel Entry) Bridge(World world)
63 {
64 var pause=new Channel(world,false);var entry=new Channel(world,true);
65 return(new(pause,entry,new Recovery(world)){PollInterval=TimeSpan.FromMilliseconds(1),StepTimeout=TimeSpan.FromMilliseconds(80)},pause,entry);
66 }
67 private static async Task Fails<T>(Func<Task> run)where T:Exception
68 {try{await run();}catch(T){return;}throw new InvalidOperationException("Expected "+typeof(T).Name);}
69 [Test]public async Task VerifiedExtensionLeavesToMenuAndEntryOnlyRestoresSolo()
70 {
71 var world=new World();var c=Bridge(world);var flow=new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1),Timeout=TimeSpan.FromSeconds(1)};
72 await flow.RunAsync();Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu,ContinueMenu,SetupPlaySolo",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&c.Entry.Ended);
73 }
74 [Test]public async Task OpenModeIsPreservedAfterTheActualMainMenu()
75 {
76 var world=new World{Mode="Open"};var c=Bridge(world);await new NativeRelogWorkflow(c.Bridge,()=>world.Player){PollInterval=TimeSpan.FromMilliseconds(1)}.RunAsync();
77 Check.Equal("SetupPlayWithEveryone",world.Calls.Last());Check.That(!world.Calls.Contains("SetupPlaySolo"));
78 }
79 [Test]public async Task LostRepliesUseObservedOutcomeWithoutRepeatingSelections()
80 {
81 var world=new World{LostReply=true,ReadFailures=2};var c=Bridge(world);await c.Bridge.BeginAsync("owner",default);await c.Bridge.EndAsync("owner");
82 Check.Equal("@pause,ExitOptions,Confirmed Quit To Main Menu",string.Join(',',world.Calls));Check.That(c.Entry.Begun&&c.Entry.Ended&&c.Pause.Ended);
83 }
84 [Test]public async Task AnUnknownExitOutcomeNeverReplaysOrStartsEntry()
85 {
86 var world=new World{DoNotApply=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));await c.Bridge.EndAsync("owner");
87 Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
88 }
89 [Test]public async Task DisabledAndAmbiguousExitOptionsFailBeforeAnyExit()
90 {
91 foreach(var world in new[]{new World{Disabled=true},new World{Duplicate=true}}){var c=Bridge(world);await Fails<InvalidDataException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause",string.Join(',',world.Calls));Check.That(!c.Entry.Begun&&c.Pause.Ended);}
92 }
93 [Test]public async Task StaleNativeFramesCannotOpenThePauseMenu()
94 {
95 var world=new World{Stale=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.That(world.Calls.Count==0&&c.Pause.Ended&&!c.Entry.Begun);
96 }
97 [Test]public async Task CancellationCleansUpPauseWithoutSelectingAnExit()
98 {
99 using var stop=new CancellationTokenSource();var world=new World{CancelAtPause=stop};var c=Bridge(world);await Fails<OperationCanceledException>(()=>c.Bridge.BeginAsync("owner",stop.Token));await c.Bridge.EndAsync("owner");
100 Check.Equal("@pause",string.Join(',',world.Calls));Check.That(c.Pause.Ended&&!c.Entry.Begun);
101 }
102 [Test]public async Task ADefinitiveFailedReceiptStopsWithoutEntering()
103 {
104 var world=new World{DoNotApply=true,Fail=true};var c=Bridge(world);await Fails<IOException>(()=>c.Bridge.BeginAsync("owner",default));Check.Equal("@pause,ExitOptions",string.Join(',',world.Calls));Check.That(!c.Entry.Begun);
105 }
106 }
Modified XFEEDToolBox.Test/NativeTradeCommandDriverTests.cs +24 -2
@@ -18,6 +18,7 @@ public class NativeTradeCommandDriverTests
18 18 public ulong ActualMarket=1;public Action? OnReadinessProbe;
19 19 public long RepairNeeded,IntegrityNeeded,RearmNeeded;public int IntegrityWear;public bool KeepServiceOutstanding;
20 20 public bool QuoteSnapshotsSupported=true,FailPostTradeReadOnce;private bool currentQuote;private ulong quotedGeneration;private readonly HashSet<string> consumedServices=[];
21 public bool ProfileRefreshSupported=true,FailClientProfile;public long ServerCreditsOffset;public int ProfileRefreshes;
21 22 public string Commodity="biowaste";public uint CommodityId=128049244;public int UnitBuy=68,UnitSell=80;public bool BatchSupported;
22 23 private ulong generation;private int pending;
23 24 private readonly Dictionary<ulong,JsonElement> replies=[];private readonly Dictionary<string,ulong> keys=[];
@@ -25,7 +26,7 @@ public class NativeTradeCommandDriverTests
25 26 public async Task<JsonElement> CallAsync(object request,CancellationToken token)
26 27 {
27 28 var command=Json(request);var op=command.GetProperty("op").GetString();
28 if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported});
29 if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported});
29 30 if(op=="commerce.inventory")return Json(new{verified=true,count=Cargo,capacity=Player.CargoCapacity,credits=Credits});
30 31 if(op=="commerce.docked.status")
31 32 {
@@ -49,7 +50,8 @@ public class NativeTradeCommandDriverTests
49 50 if(FailPostTradeReadOnce&&Cargo>0){FailPostTradeReadOnce=false;throw new IOException("Post-order read failed after original cargo/wallet confirmation.");}
50 51 MarketReads++;if(DockingPendingReads>0)throw new InvalidOperationException("Market requested before native docking settled.");
51 52 currentQuote=false;
52 var id=++generation;replies[id]=Json(new{state=3,generation=id,commodityCount=1,commodities=new[]{new{id=CommodityId,name=Commodity,buyPrice=UnitBuy,sellPrice=UnitSell}},cargo=Cargo==0?new Dictionary<string,int>():new(){[Commodity]=Cargo}});return Json(new{generation=id});
53 if(command.TryGetProperty("refreshClient",out var refresh)&&refresh.ValueKind==JsonValueKind.True)ProfileRefreshes++;
54 var id=++generation;replies[id]=Json(new{state=3,generation=id,profileApplied=!FailClientProfile,serverCredits=Credits+ServerCreditsOffset,serverCargoCount=Cargo,commodityCount=1,commodities=new[]{new{id=CommodityId,name=Commodity,buyPrice=UnitBuy,sellPrice=UnitSell}},cargo=Cargo==0?new Dictionary<string,int>():new(){[Commodity]=Cargo}});return Json(new{generation=id});
53 55 }
54 56 if(op is "station.services" or "commerce.trade" or "commerce.trade.batch")
55 57 {
@@ -84,6 +86,26 @@ public class NativeTradeCommandDriverTests
84 86 driver.WalletObserved+=observation=>channel.Player=observation.Project(channel.Player);return driver;
85 87 }
86 88 private static TradeAction Buy()=>new(TradeActionKind.Buy,new(){From=new(){MarketId=1},To=new(){MarketId=2},Orders=[new("biowaste","Biowaste",1,68,80)]},new("biowaste","Biowaste",1,68,80));
89 [Test] public async Task SourceQuoteSynchronizesAndCrossChecksTheActualClientBeforeBuying()
90 {
91 using var directory=new TemporaryDirectory();var channel=new Channel();var driver=Driver(channel,directory.Path);
92 await driver.PreflightAsync(default);await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);
93 Check.That(channel.ProfileRefreshes>0);Check.Equal(1,channel.Mutations);Check.Equal(1,channel.Player.CargoCount);
94 }
95 [Test] public async Task ServerReplyWithoutClientApplicationCannotStartAnOrder()
96 {
97 using var directory=new TemporaryDirectory();var channel=new Channel{FailClientProfile=true};var driver=Driver(channel,directory.Path);
98 await driver.PreflightAsync(default);var refused=false;
99 try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
100 Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
101 }
102 [Test] public async Task StaleClientWalletCannotBeUsedAsTheNextPurchaseBalance()
103 {
104 using var directory=new TemporaryDirectory();var channel=new Channel{ServerCreditsOffset=-26};var driver=Driver(channel,directory.Path);
105 await driver.PreflightAsync(default);var refused=false;
106 try{await driver.ExecuteTradeAsync(Buy(),_=>Task.CompletedTask,default);}catch(InvalidDataException){refused=true;}
107 Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
108 }
87 109 [Test] public async Task ParallelTradeAndMaintenanceShareTheActualWalletAcrossDrivers()
88 110 {
89 111 var directory=Path.Combine(Path.GetTempPath(),"xfe-native-commerce-"+Guid.NewGuid().ToString("N"));var channel=new Channel();
Added XFEEDToolBox/App.BackgroundDataProbe.cs +34 -0
@@ -0,0 +1,34 @@
1 using System.Diagnostics;
2 using System.Text.Json;
3
4 namespace XFEEDToolBox;
5
6 public partial class App
7 {
8 private static async Task RunBackgroundDataProbeAsync(string path)
9 {
10 var gaps = new List<double>(); var watch = Stopwatch.StartNew(); var previous = 0d;
11 var timer = MainWindow.DispatcherQueue.CreateTimer(); timer.Interval = TimeSpan.FromMilliseconds(10);
12 timer.Tick += (_, _) => { var now = watch.Elapsed.TotalMilliseconds; gaps.Add(now - previous); previous = now; };
13 object? result = null; string? error = null;
14 try
15 {
16 await ViewModel.InitializeAsync();
17 watch.Restart(); previous = 0; timer.Start();
18 await ViewModel.ReadPluginStatusCommand.ExecuteAsync(null);
19 timer.Stop();
20 result = new { uiThread = Environment.CurrentManagedThreadId, diagnostics = ViewModel.PluginQueryDiagnostics,
21 elapsedMilliseconds = watch.Elapsed.TotalMilliseconds, dispatcherTicks = gaps.Count,
22 maximumDispatchGapMilliseconds = gaps.Count == 0 ? 0 : gaps.Max(),
23 ViewModel.PluginInstalled, ViewModel.PluginNavigationReady, ViewModel.PluginRelogReady,
24 ViewModel.RelogAvailabilityText, ViewModel.PluginStatusText,
25 passed = gaps.Count >= 10 && gaps.Max() < 150 };
26 }
27 catch (Exception failure) { error = failure.ToString(); }
28 finally { timer.Stop(); }
29 Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
30 await File.WriteAllTextAsync(path, JsonSerializer.Serialize(new { result, error, gameFlightCommandsInvoked = false,
31 gameRestartInvoked = false, artificialWorkerDelayMilliseconds = 500 }, new JsonSerializerOptions { WriteIndented = true }));
32 MainWindow.Close();
33 }
34 }
Modified XFEEDToolBox/App.xaml.cs +2 -1
@@ -34,7 +34,7 @@ public partial class App : Application
34 34 Runtime = new(directory);
35 35 MainWindow = new();
36 36 ViewModel = new(Runtime, MainWindow.DispatcherQueue);
37 var hiddenProbe=GetArgument(commandLine,"--smoke-current-system") is not null||GetArgument(commandLine,"--smoke-interface") is not null||GetArgument(commandLine,"--smoke-ship-status") is not null||GetArgument(commandLine,"--smoke-refinement") is not null||GetArgument(commandLine,"--smoke-skybox") is not null||GetArgument(commandLine,"--smoke-ui") is not null||GetArgument(commandLine,"--smoke-layout") is not null||GetArgument(commandLine,"--smoke-models") is not null||GetArgument(commandLine,"--smoke-map") is not null||GetArgument(commandLine,"--accept-navigation") is not null||GetArgument(commandLine,"--accept-game-route") is not null||GetArgument(commandLine,"--check-navigation") is not null||GetArgument(commandLine,"--smoke-workspace") is not null;
37 var hiddenProbe=GetArgument(commandLine,"--check-background-data") is not null||GetArgument(commandLine,"--smoke-current-system") is not null||GetArgument(commandLine,"--smoke-interface") is not null||GetArgument(commandLine,"--smoke-ship-status") is not null||GetArgument(commandLine,"--smoke-refinement") is not null||GetArgument(commandLine,"--smoke-skybox") is not null||GetArgument(commandLine,"--smoke-ui") is not null||GetArgument(commandLine,"--smoke-layout") is not null||GetArgument(commandLine,"--smoke-models") is not null||GetArgument(commandLine,"--smoke-map") is not null||GetArgument(commandLine,"--accept-navigation") is not null||GetArgument(commandLine,"--accept-game-route") is not null||GetArgument(commandLine,"--check-navigation") is not null||GetArgument(commandLine,"--smoke-workspace") is not null;
38 38 ViewModel.InitializeHotkeys(!hiddenProbe&&!commandLine.Contains("--no-session-automation"));
39 39 MainWindow.Content = new AppShellPage();
40 40 if (commandLine.Contains("--maximized")) ((Microsoft.UI.Windowing.OverlappedPresenter)MainWindow.AppWindow.Presenter).Maximize();
@@ -44,6 +44,7 @@ public partial class App : Application
44 44 try
45 45 {
46 46 await Runtime.InitializeAsync();
47 if(GetArgument(commandLine,"--check-background-data") is {} backgroundReport){await RunBackgroundDataProbeAsync(backgroundReport);return;}
47 48 await ViewModel.InitializeAsync();
48 49 if(GetArgument(commandLine,"--automatic-trade") is {} automaticTrade){AppShellPage.Current!.Navigate(typeof(PlannerPage));await ViewModel.RunAutomaticTradeAsync(automaticTrade);return;}
49 50 if(GetArgument(commandLine,"--smoke-current-system") is {} systemReport){await RunCurrentSystemProbeAsync(systemReport);return;}
Modified XFEEDToolBox/Services/WindowsGameSessionDriver.cs +6 -3
@@ -7,7 +7,8 @@ using XFEEDToolBox.Core.Session;
7 7 namespace XFEEDToolBox.Services;
8 8
9 9 public sealed class WindowsGameSessionDriver(Func<PlayerState> player, Func<string>? configuredExecutable = null,
10 Func<bool>? relogAvailable = null, Action<string>? relogProgress = null) : IGameSessionDriver
10 Func<bool>? relogAvailable = null, Action<string>? relogProgress = null,
11 Func<int,string,CancellationToken,Task>? ensureProtection = null) : IGameSessionDriver
11 12 {
12 13 public bool CanRelog => relogAvailable?.Invoke() == true && player().InGame && NativeRelogWorkflow.SupportsMode(player().GameMode);
13 14 public async Task<GameSessionTarget> FindTargetAsync(CancellationToken token)
@@ -157,13 +158,15 @@ public sealed class WindowsGameSessionDriver(Func<PlayerState> player, Func<stri
157 158 hash = Convert.ToHexStringLower(await SHA256.HashDataAsync(image, token));
158 159 if (hash != NativeRelogChannel.SupportedGameSha256)
159 160 throw new NotSupportedException("当前游戏构建尚未匹配原生重进接口,未执行游戏操作。");
160 var channel = new NativeRelogChannel(target.ProcessId, hash, cancellation =>
161 if(ensureProtection is not null)await ensureProtection(target.ProcessId,hash,token);
162 var channel = new NativeSessionRecoveryChannel(target.ProcessId, hash, Path.Combine(AppContext.BaseDirectory,"PluginPackage","DockingControl"), cancellation =>
161 163 {
162 164 cancellation.ThrowIfCancellationRequested();
163 165 using var verified = OpenVerified(target);
164 166 return Task.CompletedTask;
165 167 });
166 var relog = new NativeRelogWorkflow(channel, player);
168 channel.Progress+=message=>relogProgress?.Invoke(message);
169 var relog = new NativeRelogWorkflow(channel, player){Timeout=TimeSpan.FromMinutes(8)};
167 170 relog.Progress += message => relogProgress?.Invoke(message);
168 171 await relog.RunAsync(token);
169 172 }
Added XFEEDToolBox/ViewModels/PluginConnectionProbe.cs +82 -0
@@ -0,0 +1,82 @@
1 using System.Diagnostics;
2 using XFEEDToolBox.Core.Execution;
3 using XFEEDToolBox.Core.Navigation;
4 using XFEEDToolBox.Core.Plugins;
5 using XFEEDToolBox.Core.Session;
6
7 namespace XFEEDToolBox.ViewModels;
8
9 public partial class ToolboxViewModel
10 {
11 private int pluginQueryWorkerThread, pluginQueryApplyThread;
12 public object PluginQueryDiagnostics => new { workerThread = Volatile.Read(ref pluginQueryWorkerThread), applyThread = pluginQueryApplyThread };
13 private sealed record PluginConnection(string Executable, PluginStatus? Status = null, PluginPackage? Package = null,
14 PluginHandshake? Handshake = null, int ProcessId = 0, bool RelogReady = false, bool TradeReady = false,
15 string Waiting = "", string RelogIssue = "", string TradeIssue = "");
16
17 // Process/module enumeration, disk validation, IPC and response decoding
18 // stay on the worker even when an awaited I/O operation completes inline.
19 private Task<PluginConnection> ReadPluginConnectionAsync(string executable, string fallback, bool inGame, CancellationToken token)
20 => Task.Run(async () =>
21 {
22 Volatile.Write(ref pluginQueryWorkerThread, Environment.CurrentManagedThreadId);
23 if (Environment.GetCommandLineArgs().Contains("--check-background-data")) await Task.Delay(500, token).ConfigureAwait(false);
24 if (executable.Length == 0) executable = fallback.Length != 0 ? fallback : FindSteamGame();
25 if (executable.Length == 0) return new PluginConnection(executable, Waiting: "未找到游戏目录,请选择 EliteDangerous64.exe。");
26 var state = await pluginManager.InspectAsync(executable, token).ConfigureAwait(false);
27 if (!state.Installed) return new PluginConnection(executable, state, Waiting: state.Message);
28 var package = pluginManager.Package();
29 var games = Process.GetProcessesByName("EliteDangerous64");
30 try
31 {
32 var matching = games.Where(p => string.Equals(p.MainModule?.FileName, executable, StringComparison.OrdinalIgnoreCase)).ToArray();
33 if (matching.Length != 1) return new PluginConnection(executable, state, package, Waiting: matching.Length == 0
34 ? "游戏尚未运行,请先启动游戏并进入飞船驾驶舱。" : "检测到多个游戏进程,请保留要控制的唯一游戏实例。");
35 var game = matching[0];
36 var loader = Path.Combine(Path.GetDirectoryName(executable)!, "dinput8.dll");
37 if (!game.Modules.Cast<ProcessModule>().Any(m => string.Equals(m.FileName, loader, StringComparison.OrdinalIgnoreCase)))
38 return new PluginConnection(executable, state, package, Waiting: "插件尚未加载,请在游戏下次启动后重试。");
39 var hello = await GamePluginManager.ProbeAsync(game.Id, token: token).ConfigureAwait(false);
40 // Observers and extension loading wait for an actual Journal session.
41 if (inGame && !state.PendingRemoval && hello.Loaded && hello.Active && package.SupportsNavigation && !hello.ObservationStarted)
42 hello = await GamePluginManager.StartNavigationObservationAsync(game.Id, token).ConfigureAwait(false);
43 var relog = false; var trade = false; var relogIssue = "当前插件尚未提供可用的重进接口"; var tradeIssue = "";
44 if (inGame && !state.PendingRemoval && hello.Loaded && hello.Active)
45 {
46 if (hello.SupportsRelog && package.SupportsRelog)
47 {
48 try
49 {
50 await new NativeOriginalSessionMenuRecovery(game.Id, state.Installation!.GameSha256,
51 Path.Combine(AppContext.BaseDirectory, "PluginPackage", "DockingControl")).PrepareAsync(token).ConfigureAwait(false);
52 relog = true; relogIssue = "";
53 }
54 catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
55 catch (Exception error) { relogIssue = "重进连接未就绪:" + error.Message; }
56 }
57 if (hello.SupportsNavigation && package.SupportsNavigation
58 && (runtime.FlightDriver is not NativeFlightControlDriver current || current.ProcessId != game.Id))
59 {
60 var processId = game.Id; var hash = state.Installation!.GameSha256;
61 await runtime.BindFlightDriverAsync(new NativeFlightControlDriver(processId, hash,
62 () => runtime.Player, () => runtime.Settings.NavigationFuelReserve,
63 new NativeGameInputChannel(processId, hash, Path.Combine(AppContext.BaseDirectory, "PluginPackage")),
64 cancellation => EnsureNativeFlightProtectionAsync(processId, hash, cancellation)), token).ConfigureAwait(false);
65 }
66 if (runtime.FlightDriver is NativeFlightControlDriver driver && driver.ProcessId == game.Id)
67 {
68 try
69 {
70 var capabilities = await new NativeCommerceChannel(game.Id).CallAsync(new { op = "describe" }, token).ConfigureAwait(false);
71 trade = NativeTradeCapabilities.SupportsDockedTrade(capabilities);
72 }
73 catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; }
74 catch (Exception error) { tradeIssue = error.Message; }
75 }
76 }
77 return new PluginConnection(executable, state, package, hello, game.Id, relog, trade,
78 RelogIssue: relogIssue, TradeIssue: tradeIssue);
79 }
80 finally { foreach (var game in games) game.Dispose(); }
81 }, token);
82 }
Modified XFEEDToolBox/ViewModels/PluginViewModel.cs +45 -55
@@ -19,15 +19,19 @@ public partial class ToolboxViewModel
19 19 private DateTimeOffset? nativeSessionSeen;
20 20 private bool nativeStatusChecking;
21 21 private Task? nativeStatusRead;
22 private DateTimeOffset nativeStatusRetryAt;
23 private bool nativeStatusConnected;
22 24 private void ObserveNativeSession(PlayerState player)
23 25 {
24 if(!player.InGame){PluginNavigationReady=false;runtime.UnbindFlightDriver();return;}
25 if(nativeStatusChecking||player.LoadGameAt is null||nativeSessionSeen==player.LoadGameAt)return;
26 if(!player.InGame){PluginNavigationReady=false;PluginRelogReady=false;nativeSessionSeen=null;nativeStatusConnected=false;runtime.UnbindFlightDriver();return;}
27 if(nativeStatusChecking||player.LoadGameAt is null||nativeSessionSeen==player.LoadGameAt&&(nativeStatusConnected||DateTimeOffset.UtcNow<nativeStatusRetryAt))return;
28 if(NavigationActive||PluginBusy)return;
26 29 nativeSessionSeen=player.LoadGameAt;
27 if(!NavigationActive&&!PluginBusy)_=ReadPluginStatusAsync();
30 nativeStatusRetryAt=DateTimeOffset.UtcNow.AddSeconds(3);
31 _=ReadPluginStatusAsync();
28 32 }
29 33 private void InitializePluginSettings()
30 { PluginGameExecutable=runtime.Settings.PluginGameExecutable;if(PluginGameExecutable.Length==0)PluginGameExecutable=FindSteamGame(); }
34 { PluginGameExecutable=runtime.Settings.PluginGameExecutable; }
31 35 private static string FindSteamGame()
32 36 {
33 37 var steam=Microsoft.Win32.Registry.GetValue(@"HKEY_CURRENT_USER\Software\Valve\Steam","SteamPath",null) as string;if(steam is null)return "";
@@ -45,63 +49,49 @@ public partial class ToolboxViewModel
45 49 private async Task ReadPluginStatusCoreAsync()
46 50 {
47 51 nativeStatusChecking=true;
48 PluginTradeReady=false;PluginNavigationReady=false;
52 nativeStatusConnected=false;RelogUnavailableReason="正在连接当前游戏的重进接口";RefreshRelogAvailability();
53 PluginTradeReady=false;PluginNavigationReady=false;PluginRelogReady=false;
49 54 NavigationNativeStatus="正在检查游戏插件与导航连接…";
55 var session=CurrentPlayer.LoadGameAt;var inGame=CurrentPlayer.InGame;
50 56 try
51 57 {
52 if(PluginGameExecutable.Length==0)PluginGameExecutable=gameEnvironment?.Executable is { Length:>0 } file?file:FindSteamGame();
53 if(PluginGameExecutable.Length==0){PluginInstalled=false;PluginStatusText="未找到游戏目录,请选择 EliteDangerous64.exe。";NavigationNativeStatus=PluginStatusText;return;}
54 var state=await pluginManager.InspectAsync(PluginGameExecutable,lifetime.Token);PluginInstalled=state.Installed;PluginStatusText=state.Message;if(!state.Installed){NavigationNativeStatus=PluginStatusText;return;}
55 var games=Process.GetProcessesByName("EliteDangerous64");
56 try
58 var connection=await ReadPluginConnectionAsync(PluginGameExecutable,gameEnvironment?.Executable??"",inGame,lifetime.Token);
59 pluginQueryApplyThread=Environment.CurrentManagedThreadId;
60 PluginGameExecutable=connection.Executable;PluginInstalled=connection.Status?.Installed==true;
61 if(connection.Status is {} status)PluginStatusText=status.Message;
62 if(connection.Waiting.Length!=0){NavigationNativeStatus=PluginStatusText=connection.Waiting;RelogUnavailableReason=connection.Waiting;return;}
63 if(CurrentPlayer.LoadGameAt!=session||CurrentPlayer.InGame!=inGame){nativeSessionSeen=null;return;}
64 var state=connection.Status!;var package=connection.Package!;var hello=connection.Handshake!;
65 nativeStatusConnected=connection.RelogReady||!inGame;
66 PluginRelogReady=connection.RelogReady;RelogUnavailableReason=connection.RelogIssue;
67 PluginTradeReady=connection.TradeReady;
68 if(connection.TradeIssue.Length!=0)runtime.Log("交易接口",connection.TradeIssue);
69 if(inGame&&!connection.RelogReady&&connection.RelogIssue.Length!=0)runtime.Log("重进接口",connection.RelogIssue);
70 PluginNavigationReady=inGame&&!state.PendingRemoval&&hello.Loaded&&hello.Active&&hello.SupportsNavigation&&package.SupportsNavigation
71 &&runtime.FlightDriver is NativeFlightControlDriver driver&&driver.ProcessId==connection.ProcessId;
72 PluginStatusText=$"插件已加载 · PID {connection.ProcessId} · {hello.Version}\n"+(hello.Active?"基础通信可用。\n"+(PluginNavigationReady?"自动导航驱动已就绪。":"等待进入飞行状态并完成原生遥测握手。")+"\n"+(PluginTradeReady?"自动贸易接口已就绪。":"自动贸易接口尚未就绪。")+"\n"+(PluginRelogReady?"原生重进接口已连接。":RelogUnavailableReason):"插件已停用,加载文件等待游戏退出后清理。");
73 NavigationNativeStatus=PluginNavigationReady?"原生导航已连接:姿态、实时热量、恒星边界、目标锁定和充能等待联锁。":hello.Active?"插件已加载,等待飞行状态和原生遥测。":"插件已停用。";
74 if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsReferenceGeometry:false})
57 75 {
58 var matching=games.Where(p=>string.Equals(p.MainModule?.FileName,PluginGameExecutable,StringComparison.OrdinalIgnoreCase)).ToArray();if(matching.Length!=1){NavigationNativeStatus=matching.Length==0?"游戏尚未运行,请先启动游戏并进入飞船驾驶舱。":"检测到多个游戏进程,请保留要控制的唯一游戏实例。";return;}
59 var game=matching[0];var loader=Path.Combine(Path.GetDirectoryName(PluginGameExecutable)!,"dinput8.dll");
60 if(!game.Modules.Cast<ProcessModule>().Any(m=>string.Equals(m.FileName,loader,StringComparison.OrdinalIgnoreCase))){NavigationNativeStatus="插件尚未加载,请在游戏下次启动后重试。";return;}
61 var hello=await GamePluginManager.ProbeAsync(game.Id,token:lifetime.Token);
62 if(!state.PendingRemoval&&hello.Loaded&&hello.Active&&pluginManager.Package().SupportsNavigation&&!hello.ObservationStarted)
63 hello=await GamePluginManager.StartNavigationObservationAsync(game.Id,lifetime.Token);
64 if(!state.PendingRemoval&&hello.Loaded&&hello.Active&&hello.SupportsNavigation&&pluginManager.Package().SupportsNavigation)
65 {
66 if(runtime.FlightDriver is not NativeFlightControlDriver current||current.ProcessId!=game.Id)
67 await runtime.BindFlightDriverAsync(new NativeFlightControlDriver(game.Id,state.Installation!.GameSha256,()=>runtime.Player,()=>runtime.Settings.NavigationFuelReserve,
68 new NativeGameInputChannel(game.Id,state.Installation.GameSha256,Path.Combine(AppContext.BaseDirectory,"PluginPackage"))),lifetime.Token);
69 }
70 if (!state.PendingRemoval && hello.Loaded && hello.Active && runtime.FlightDriver is NativeFlightControlDriver tradeFlight)
71 {
72 try
73 {
74 var capabilities = await new NativeCommerceChannel(tradeFlight.ProcessId).CallAsync(new { op = "describe" }, lifetime.Token);
75 PluginTradeReady = NativeTradeCapabilities.SupportsDockedTrade(capabilities);
76 }
77 catch (OperationCanceledException) when (lifetime.IsCancellationRequested) { throw; }
78 catch (Exception error) { runtime.Log("交易接口", error.Message); }
79 }
80 PluginNavigationReady=!state.PendingRemoval&&hello.Loaded&&hello.Active&&hello.SupportsNavigation&&pluginManager.Package().SupportsNavigation&&runtime.FlightDriver is not null;
81 PluginStatusText=$"插件已加载 · PID {game.Id} · {hello.Version}\n"+(hello.Active?"基础通信可用。\n"+(PluginNavigationReady?"自动导航驱动已就绪。":"等待进入飞行状态并完成原生遥测握手。")+"\n"+(PluginTradeReady?"自动贸易接口已就绪。":"自动贸易与底层重进适配尚未完成。"):"插件已停用,加载文件等待游戏退出后清理。");
82 NavigationNativeStatus=PluginNavigationReady?"原生导航已连接:姿态、实时热量、恒星边界、目标锁定和充能等待联锁。":hello.Active?"插件已加载,等待飞行状态和原生遥测。":"插件已停用。";
83 if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsReferenceGeometry:false})
84 {
85 NavigationNativeStatus="已连接旧版导航插件;空间站与行星参考系需要更新插件后重启游戏。";
86 PluginStatusText+=$"\n可更新至 {pluginManager.Package().Version},补齐行星与空间站参考系。请退出游戏后点击安装 / 更新。";
87 }
88 else if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsAdaptiveFlightControl:false})
89 {
90 NavigationNativeStatus="当前加载的是旧版导航插件;更新并重启游戏后启用快速对齐与高功率补给。";
91 PluginStatusText+=$"\n可更新至 {pluginManager.Package().Version},启用新的转向、热量预测和补给控制。";
92 }
93 else if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsApplicationSteeringAxes:false})
94 {
95 PluginNavigationReady=false;
96 NavigationNativeStatus="当前交互接口尚未支持绕星三轴姿态;请在结束游玩后更新游戏插件。";
97 PluginStatusText+=$"\n可更新至 {pluginManager.Package().Version},提供软件控制的偏航、俯仰和滚转输入。";
98 }
99 if(PluginNavigationReady)await EnsureGalaxyDisplayAsync();
76 NavigationNativeStatus="已连接旧版导航插件;空间站与行星参考系需要更新插件后重启游戏。";
77 PluginStatusText+=$"\n可更新至 {package.Version},补齐行星与空间站参考系。请退出游戏后点击安装 / 更新。";
100 78 }
101 finally{foreach(var game in games)game.Dispose();}
79 else if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsAdaptiveFlightControl:false})
80 {
81 NavigationNativeStatus="当前加载的是旧版导航插件;更新并重启游戏后启用快速对齐与高功率补给。";
82 PluginStatusText+=$"\n可更新至 {package.Version},启用新的转向、热量预测和补给控制。";
83 }
84 else if(PluginNavigationReady&&runtime.FlightDriver is NativeFlightControlDriver{SupportsApplicationSteeringAxes:false})
85 {
86 PluginNavigationReady=false;
87 NavigationNativeStatus="当前交互接口尚未支持绕星三轴姿态;请在结束游玩后更新游戏插件。";
88 PluginStatusText+=$"\n可更新至 {package.Version},提供软件控制的偏航、俯仰和滚转输入。";
89 }
90 if(PluginNavigationReady)await EnsureGalaxyDisplayAsync();
102 91 }
103 catch(Exception e){PluginTradeReady=false;PluginStatusText=(PluginInstalled?"插件已安装,实时握手未就绪:":"插件检查失败:")+e.Message;NavigationNativeStatus=PluginStatusText;}
104 finally{nativeStatusChecking=false;RefreshNavigationStartHint();}
92 catch(OperationCanceledException)when(lifetime.IsCancellationRequested){}
93 catch(Exception e){nativeStatusConnected=false;PluginRelogReady=false;RelogUnavailableReason="重进连接未就绪:"+e.Message;PluginTradeReady=false;PluginStatusText=(PluginInstalled?"插件已安装,实时握手未就绪:":"插件检查失败:")+e.Message;NavigationNativeStatus=PluginStatusText;}
94 finally{nativeStatusChecking=false;RefreshNavigationStartHint();RefreshRelogAvailability();}
105 95 }
106 96 [RelayCommand] private async Task InstallPluginAsync(CancellationToken cancellationToken=default)
107 97 {
Added XFEEDToolBox/ViewModels/SessionRelogViewModel.cs +38 -0
@@ -0,0 +1,38 @@
1 using CommunityToolkit.Mvvm.ComponentModel;
2 using XFEEDToolBox.Core.Session;
3
4 namespace XFEEDToolBox.ViewModels;
5
6 public partial class ToolboxViewModel
7 {
8 [ObservableProperty, NotifyPropertyChangedFor(nameof(RelogAvailabilityText))]
9 public partial bool PluginRelogReady { get; set; }
10 [ObservableProperty, NotifyPropertyChangedFor(nameof(RelogAvailabilityText))]
11 public partial string RelogUnavailableReason { get; set; }="等待当前游戏的重进连接";
12
13 public string RelogAvailabilityText => !CurrentPlayer.InGame ? "进入游戏后可重进"
14 : nativeStatusChecking ? "正在连接当前游戏的重进接口"
15 : !NativeRelogWorkflow.SupportsMode(CurrentPlayer.GameMode) ? "私人群组重进尚未接入"
16 : !PluginRelogReady ? RelogUnavailableReason
17 : "返回主菜单并重新进入" + (CurrentPlayer.GameMode == "Solo" ? "单人模式" : "公开模式");
18
19 partial void OnPluginRelogReadyChanged(bool value) => RefreshRelogAvailability();
20 partial void OnSystemFlightActiveChanged(bool value)
21 {
22 RefreshRelogAvailability();
23 StartGameCommand.NotifyCanExecuteChanged();
24 RestartGameCommand.NotifyCanExecuteChanged();
25 }
26
27 private void RefreshRelogAvailability()
28 {
29 OnPropertyChanged(nameof(RelogAvailabilityText));
30 RelogGameCommand.NotifyCanExecuteChanged();
31 }
32
33 private void ReportRelogProgress(string message) => dispatcher.TryEnqueue(() =>
34 {
35 SessionMessage = message;
36 runtime.Log("会话", message);
37 });
38 }
Modified XFEEDToolBox/ViewModels/ToolboxViewModel.cs +4 -2
@@ -146,7 +146,8 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
146 146 {
147 147 this.runtime = runtime; this.dispatcher = dispatcher;
148 148 systemNavigator=new(()=>runtime.Player);systemNavigator.Progress+=OnSystemFlightProgress;
149 sessions = new(new WindowsGameSessionDriver(() => runtime.Player, () => PluginGameExecutable), () => RunActive||NavigationActive);
149 sessions = new(new WindowsGameSessionDriver(() => runtime.Player, () => PluginGameExecutable,
150 () => PluginRelogReady, ReportRelogProgress, EnsureNativeFlightProtectionAsync), () => RunActive||NavigationActive||SystemFlightActive);
150 151 sessions.Progress += p => dispatcher.TryEnqueue(() => { if (p.Message.Length > 0) { SessionMessage = p.Message; runtime.Log("会话", p.Message); } });
151 152 JournalDirectory = runtime.Settings.JournalDirectory; EddnEndpoint = runtime.Settings.EddnEndpoint;
152 153 EddnOnStartup = runtime.Settings.ConnectEddnOnStart;
@@ -295,6 +296,7 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
295 296 }
296 297 if((changes&RuntimeChange.Player)!=0)
297 298 {
299 RefreshRelogAvailability();
298 300 Commander=p.Commander;RefreshProfileName();Ship=string.IsNullOrEmpty(p.Ship)?"舰船数据未知":Core.Localization.ChineseNames.Ship(p.Ship);
299 301 RefreshProfileData(p);
300 302 CreditsText=p.Credits?.ToString("N0")??"—";CargoText=$"{p.CargoCount:N0} / {p.CargoCapacity:N0} t";
@@ -358,7 +360,7 @@ public partial class ToolboxViewModel : ViewModelBase, IDisposable
358 360 StartNavigationCommand.NotifyCanExecuteChanged();
359 361 }
360 362 partial void OnRunActiveChanged(bool value) { RefreshTradePlanCommands(); StartGameCommand.NotifyCanExecuteChanged(); RelogGameCommand.NotifyCanExecuteChanged(); RestartGameCommand.NotifyCanExecuteChanged();StartNavigationCommand.NotifyCanExecuteChanged(); }
361 private bool CanManageSession() => initialized && !DemoMode && !RunActive && !SessionBusy&&!NavigationActive;
363 private bool CanManageSession() => initialized && !DemoMode && !RunActive && !SessionBusy&&!NavigationActive&&!SystemFlightActive;
362 364 private bool CanRelog() => CanManageSession() && sessions.CanRelog;
363 365 [RelayCommand(CanExecute = nameof(CanManageSession))] private Task StartGameAsync() => RunSessionAsync(SessionOperation.Start);
364 366 [RelayCommand(CanExecute = nameof(CanRelog))] private Task RelogGameAsync() => RunSessionAsync(SessionOperation.Relog);
Modified XFEEDToolBox/Views/MainPage.xaml +1 -1
@@ -38,7 +38,7 @@
38 38 </StackPanel>
39 39 <Grid x:Name="SecondaryColumn" Grid.Column="1" RowSpacing="16"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions>
40 40 <Border Style="{StaticResource DashboardCard}"><StackPanel Spacing="12"><TextBlock Text="快捷操作" Style="{StaticResource SectionTitle}"/>
41 <Button controls:AsyncAction.Command="{x:Bind ViewModel.RelogGameCommand}" controls:AsyncAction.CancelCommand="{x:Bind ViewModel.CancelSessionCommand}" controls:AsyncAction.IsRunning="{x:Bind ViewModel.ReloggingGame,Mode=OneWay}" Style="{StaticResource DashboardAction}"><Grid ColumnSpacing="14"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><FontIcon Glyph="&#xE72C;" FontSize="25"/><StackPanel Grid.Column="1" Spacing="5"><TextBlock Text="一键重进" FontWeight="SemiBold"/><TextBlock Text="返回主菜单 · 接口验证中" Style="{StaticResource MutedText}" FontSize="11"/></StackPanel><FontIcon Grid.Column="2" Glyph="&#xE76C;" FontSize="12"/></Grid></Button>
41 <Button AutomationProperties.AutomationId="RelogGameButton" controls:AsyncAction.Command="{x:Bind ViewModel.RelogGameCommand}" controls:AsyncAction.CancelCommand="{x:Bind ViewModel.CancelSessionCommand}" controls:AsyncAction.IsRunning="{x:Bind ViewModel.ReloggingGame,Mode=OneWay}" Style="{StaticResource DashboardAction}"><Grid ColumnSpacing="14"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><FontIcon Glyph="&#xE72C;" FontSize="25"/><StackPanel Grid.Column="1" Spacing="5"><TextBlock Text="一键重进" FontWeight="SemiBold"/><TextBlock Text="{x:Bind ViewModel.RelogAvailabilityText,Mode=OneWay}" Style="{StaticResource MutedText}" FontSize="11"/></StackPanel><FontIcon Grid.Column="2" Glyph="&#xE76C;" FontSize="12"/></Grid></Button>
42 42 <Button controls:AsyncAction.Command="{x:Bind ViewModel.RestartGameCommand}" controls:AsyncAction.CancelCommand="{x:Bind ViewModel.CancelSessionCommand}" controls:AsyncAction.IsRunning="{x:Bind ViewModel.RestartingGame,Mode=OneWay}" Style="{StaticResource DashboardAction}"><Grid ColumnSpacing="14"><Grid.ColumnDefinitions><ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><FontIcon Glyph="&#xE8BB;" FontSize="25"/><StackPanel Grid.Column="1" Spacing="5"><TextBlock Text="一键大退" FontWeight="SemiBold"/><TextBlock Text="结束游戏进程后重新启动" Style="{StaticResource MutedText}" FontSize="11"/></StackPanel><FontIcon Grid.Column="2" Glyph="&#xE76C;" FontSize="12"/></Grid></Button>
43 43 <Grid ColumnSpacing="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><TextBlock Text="{x:Bind ViewModel.HotkeyStatus,Mode=OneWay}" Style="{StaticResource MutedText}" FontSize="11" TextTrimming="CharacterEllipsis" MaxLines="1" VerticalAlignment="Center" ToolTipService.ToolTip="{x:Bind ViewModel.HotkeyStatus,Mode=OneWay}"/><HyperlinkButton Grid.Column="1" Content="快捷键设置" Click="OpenSettings" Padding="0,4" FontSize="11"/></Grid>
44 44 <Border BorderBrush="{StaticResource PanelStrokeBrush}" BorderThickness="0,1,0,0" Padding="0,14,0,0"><StackPanel Spacing="7">
Modified docs/TRADE-WORK-COORDINATION.md +11 -0
Added native/ManagedPlugin/Navigation.CommerceCompletion.cs +106 -0
Added native/ManagedPlugin/Navigation.CommerceProfile.cs +64 -0
Modified native/ManagedPlugin/Navigation.CommerceRead.cs +43 -24
Modified native/ManagedPlugin/Navigation.CommerceTrade.cs +3 -1
Modified native/ManagedPlugin/Navigation.Protocol.cs +1 -0
Modified native/ManagedPlugin/Platform.cs +1 -1
Modified native/ManagedPlugin/PluginLoader.cs +10 -1