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