XFEstudio/XFEEDToolBox
Match native trade validation to the actual market response
A market read supplies inventory and quotes without a wallet field. Accept that schema while retaining native wallet, stock, price, capacity, reserve and full server/client settlement checks. Preserve optional wallet mismatch rejection. Reconcile only unaccepted operation keys from the exact original live process and LoadGame session, retaining the original intent and proof. Package 0.5.17-dev and the SCO extension, retain the original 26 exports and ABI 1, and let the updater finish cleanup after a verified menu process exit. Verified by 33 native fixture checks, 29 trade driver tests, 4 unsubmitted-intent tests, 2 menu-exit tests and a real toolbox-owned 362t Uranium purchase with serverConfirmed/clientApplied true. The toolbox has resumed transport independently.
378d6db
代码差异
@@ -33,3 +33,4 @@ XFEEDToolBox/PluginPackage/interaction.json
!.env.example
XFEEDToolBox/PluginPackage/DockingControl/
XFEEDToolBox/PluginPackage/CruiseControl/
@@ -75,7 +75,10 @@ public sealed class NativeTradeCommandDriver:IConfirmedTradeCommandDriver,IConfi
if(batchPolicy is not null&&(!hello.TryGetProperty("formalCommodityTrading",out var batches)||batches.ValueKind!=JsonValueKind.True))
throw new NativeTradeSdkUpgradeRequiredException("游戏当前加载的是旧验收接口;通用跑商需要加载新版原生插件。");
if(batchPolicy is not null&&!marketClientRefresh)throw new NativeTradeSdkUpgradeRequiredException("当前插件缺少服务器库存与客户端同步核对,尚未启动批量采购。");
if(batchPolicy is not null&&(!hello.TryGetProperty("marketWalletOptional",out var walletSchema)||walletSchema.ValueKind!=JsonValueKind.True))
throw new NativeTradeSdkUpgradeRequiredException("原生采购入口仍要求行情中不存在的钱包字段,需要加载修正后的原生插件。");
session=player();ValidateSession();await ensureProtection(token);
await NativeUnsubmittedTradeIntents.ReconcileAsync(directory,channel,session,token);
foreach(var file in Directory.EnumerateFiles(directory,"*.pending.json"))
if(!File.Exists(file.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal))&&!ExternalTradeSettlement.IsSettled(file,session))throw new InvalidOperationException("存在尚未核对的原生订单,正在等待实际结果;不会重复下单。");
await gate.WaitAsync(token);try{PublishWallet(await channel.CallAsync(new{op="commerce.inventory"},token));}finally{gate.Release();}
@@ -0,0 +1,35 @@
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Core.Execution;
/// <summary>Reconciles an unaccepted key only in the exact original live game session.</summary>
public static class NativeUnsubmittedTradeIntents
{
public static async Task ReconcileAsync(string directory,IOriginalCommerceChannel channel,PlayerState player,CancellationToken token)
{
if(!Directory.Exists(directory)||!player.InGame||!player.Docked||!player.CargoReliable||!player.CreditsReliable)return;
foreach(var pending in Directory.EnumerateFiles(directory,"*.pending.json"))
{
var confirmed=pending.Replace(".pending.json",".confirmed.json",StringComparison.Ordinal);
if(File.Exists(confirmed))continue;
var bytes=await File.ReadAllBytesAsync(pending,token);using var document=JsonDocument.Parse(bytes);var row=document.RootElement;
if(row.GetProperty("channel").GetString()!=channel.Identity||row.GetProperty("CommanderId").GetString()!=player.CommanderId
||row.GetProperty("CurrentShipId").GetInt64()!=player.CurrentShipId||row.GetProperty("LoadGameAt").GetDateTimeOffset()!=player.LoadGameAt)continue;
var key=row.GetProperty("operationKey").GetString()!;
var status=await channel.CallAsync(new{op="commerce.operation.status",operationKey=key},token);
// The native map retains every accepted key for this process's
// lifetime. A new process/session may never use absence as proof.
if(status.GetProperty("known").GetBoolean()||status.GetProperty("mutationsSent").GetInt32()!=0)continue;
var before=row.GetProperty("intent").GetProperty("before");
var current=await channel.CallAsync(new{op="commerce.inventory"},token);
if(!current.GetProperty("verified").GetBoolean()||!before.GetProperty("verified").GetBoolean()
||current.GetProperty("count").GetInt32()!=before.GetProperty("count").GetInt32()
||current.GetProperty("credits").GetInt64()!=before.GetProperty("credits").GetInt64())continue;
await using var file=new FileStream(confirmed,FileMode.CreateNew,FileAccess.Write,FileShare.Read);
await JsonSerializer.SerializeAsync(file,new{operationKey=key,submitted=false,reconciledAt=DateTimeOffset.UtcNow,channel=channel.Identity,
pendingSha256=Convert.ToHexStringLower(SHA256.HashData(bytes)),originalOperationStatus=status,actualClientInventory=current},cancellationToken:token);
}
}
}
@@ -3,6 +3,11 @@ namespace XFEEDToolBox.Core.Session;
/// <summary>An update may finish a client only after its original world session has ended.</summary>
public static class SessionMaintenanceExit
{
public static async Task ReleaseAfterExitAsync(Func<Task> release,Func<bool> confirmedMenuExit)
{
try{await release().ConfigureAwait(false);}
catch(Exception error)when(confirmedMenuExit()&&error is IOException or InvalidDataException or OperationCanceledException){}
}
public static void RequireMainMenu(NativeSessionFrame frame,DateTimeOffset now)
{
var age=now-frame.ObservedAt;
@@ -18,7 +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,MarketHasCredits=true,MissingServerCargo;public long ServerCreditsOffset;public int ProfileRefreshes,ServerCargoOffset;
public bool ProfileRefreshSupported=true,MarketWalletOptional=true,MarketHasCredits=true,MissingServerCargo;public long ServerCreditsOffset;public int ProfileRefreshes,ServerCargoOffset;
public string Commodity="biowaste";public uint CommodityId=128049244;public int UnitBuy=68,UnitSell=80;public bool BatchSupported;
public bool Goals;public long Contribution;public int CgReads;
private ulong generation;private int pending;
@@ -27,7 +27,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,formalCommodityTrading=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported});
if(op=="describe")return Json(new{schema=1,name="XFEEDToolBox.Navigation",verified=true,gameSha256=NativeSystemLocationReader.GameSha256,commerceDockedReadiness=true,cgCommodityBatches=BatchSupported,formalCommodityTrading=BatchSupported,serviceQuoteSnapshots=QuoteSnapshotsSupported,commerceClientProfileRefresh=ProfileRefreshSupported,marketWalletOptional=MarketWalletOptional});
if(op is "cg.read" or "cg.read.mine")
{
if(!Goals||command.GetProperty("marketId").GetInt64()!=99)throw new InvalidOperationException("CG was queried outside the configured destination");
@@ -102,6 +102,12 @@ 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 TheKnownUnsupportedMarketSchemaIsRejectedBeforeCreatingAnyOrder()
{
using var directory=new TemporaryDirectory();var channel=new Channel{BatchSupported=true,MarketWalletOptional=false};var driver=Driver(channel,directory.Path,new(16,100));var refused=false;
try{await driver.PreflightAsync(default);}catch(NativeTradeSdkUpgradeRequiredException){refused=true;}
Check.That(refused);Check.Equal(0,channel.Mutations);Check.Equal(0,channel.MarketReads);Check.Equal(0,Directory.GetFiles(directory.Path,"*.pending.json").Length);
}
[Test] public async Task SourceQuoteCrossChecksTheActualClientWithoutInventingAProfile()
{
using var directory=new TemporaryDirectory();var channel=new Channel{MarketHasCredits=false};var driver=Driver(channel,directory.Path);
@@ -0,0 +1,60 @@
using System.Text.Json;
using XFEExtension.NetCore.XUnit.Attributes;
using XFEEDToolBox.Core.Execution;
using XFEEDToolBox.Core.Models;
namespace XFEEDToolBox.Test;
[TestFixture]
public class NativeUnsubmittedTradeIntentsTests
{
private sealed class Channel:IOriginalCommerceChannel
{
public string Identity=>"100:200";
public bool Known;
public int Calls;
public long Credits=10000;
public Task<JsonElement> CallAsync(object request,CancellationToken token)
{
Calls++;var op=JsonSerializer.SerializeToElement(request).GetProperty("op").GetString();
return Task.FromResult(op switch{
"commerce.operation.status"=>JsonSerializer.SerializeToElement(new{known=Known,mutationsSent=Known?1:0}),
"commerce.inventory"=>JsonSerializer.SerializeToElement(new{verified=true,count=0,credits=Credits}),
_=>throw new InvalidOperationException("Only read-only reconciliation is allowed")});
}
}
private static PlayerState Player()=>new(){InGame=true,Docked=true,CommanderId="test-commander",CurrentShipId=7,
LoadGameAt=DateTimeOffset.UtcNow.AddMinutes(-1),Credits=10000,CreditsReliable=true,CargoReliable=true};
private static string Pending(string directory,Channel channel,PlayerState player)
{
var file=Path.Combine(directory,new string('a',32)+".pending.json");
File.WriteAllText(file,JsonSerializer.Serialize(new{operationKey=new string('a',32),channel=channel.Identity,player.CommanderId,player.CurrentShipId,player.LoadGameAt,
intent=new{before=new{verified=true,count=0,credits=10000}}}));return file;
}
[Test] public async Task SameLiveSessionUnacceptedKeyIsMarkedUnsubmittedWithoutDeletingTheIntent()
{
using var directory=new TemporaryDirectory();var channel=new Channel();var player=Player();var pending=Pending(directory.Path,channel,player);var original=File.ReadAllText(pending);
await NativeUnsubmittedTradeIntents.ReconcileAsync(directory.Path,channel,player,default);
using var proof=JsonDocument.Parse(File.ReadAllText(pending.Replace(".pending.json",".confirmed.json")));
Check.That(!proof.RootElement.GetProperty("submitted").GetBoolean());Check.That(!proof.RootElement.TryGetProperty("receipt",out _));
Check.Equal(original,File.ReadAllText(pending));Check.Equal(2,channel.Calls);
}
[Test] public async Task NewSessionCannotUseTheOldOperationMapAsProof()
{
using var directory=new TemporaryDirectory();var channel=new Channel();var player=Player();Pending(directory.Path,channel,player);
await NativeUnsubmittedTradeIntents.ReconcileAsync(directory.Path,channel,player with{LoadGameAt=DateTimeOffset.UtcNow},default);
Check.Equal(0,channel.Calls);Check.Equal(0,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
}
[Test] public async Task AcceptedKeyRemainsUnresolved()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Known=true};var player=Player();Pending(directory.Path,channel,player);
await NativeUnsubmittedTradeIntents.ReconcileAsync(directory.Path,channel,player,default);
Check.Equal(1,channel.Calls);Check.Equal(0,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
}
[Test] public async Task ChangedWalletCannotBeUsedAsUnsubmittedProof()
{
using var directory=new TemporaryDirectory();var channel=new Channel{Credits=9999};var player=Player();Pending(directory.Path,channel,player);
await NativeUnsubmittedTradeIntents.ReconcileAsync(directory.Path,channel,player,default);
Check.Equal(0,Directory.GetFiles(directory.Path,"*.confirmed.json").Length);
}
}
@@ -6,6 +6,14 @@ namespace XFEEDToolBox.Test;
[TestFixture]
public class SessionMaintenanceExitTests
{
[Test] public async Task AClosedPipeIsHarmlessOnlyAfterConfirmedMenuExit()
{
await SessionMaintenanceExit.ReleaseAfterExitAsync(()=>throw new InvalidDataException("Pipe closed with process"),()=>true);
var refused=false;
try{await SessionMaintenanceExit.ReleaseAfterExitAsync(()=>throw new InvalidDataException("Pipe closed before exit"),()=>false);}
catch(InvalidDataException){refused=true;}
Check.That(refused);
}
[Test] public void OnlyFreshStableOriginalMainMenuPermitsMaintenanceExit()
{
var now=DateTimeOffset.UtcNow;var frame=new NativeSessionFrame(1,now,"MainMenu","MainMenu",[]);
@@ -7,7 +7,7 @@ using XFEEDToolBox.Core.Session;
namespace XFEEDToolBox.Services;
public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string executable,string packageDirectory)
public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string executable,string packageDirectory,string? operationDirectory=null)
{
public event Action<string>? Progress;
public async Task UpgradeAndEnterAsync(CancellationToken token)
@@ -39,12 +39,14 @@ public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string exec
{
if(original.InGame&&(!original.Docked||!original.CargoReliable||original.CargoCount!=0||original.CurrentShipId is null))
throw new InvalidOperationException("更新前需要确认停靠和空货舱;当前游戏已保留。");
if(operationDirectory is not null)
await NativeUnsubmittedTradeIntents.ReconcileAsync(operationDirectory,new NativeCommerceChannel(target.ProcessId),original,token).ConfigureAwait(false);
using var game=Process.GetProcessById(target.ProcessId);
if(game.StartTime.ToUniversalTime().Ticks!=target.StartedUtcTicks||!string.Equals(game.MainModule?.FileName,target.Executable,StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("准备更新的游戏实例已改变。");
Progress?.Invoke("正在通过游戏原始菜单退出当前会话以更新插件");
var recovery=new NativeSessionRecoveryChannel(target.ProcessId,NativeRelogChannel.SupportedGameSha256,Path.Combine(packageDirectory,"DockingControl"));
var owner=Guid.NewGuid().ToString("N");
var owner=Guid.NewGuid().ToString("N");var endedAtMenu=false;
recovery.Progress+=text=>Progress?.Invoke(text);
try
{
@@ -53,8 +55,9 @@ public sealed class WindowsTradingBootstrap(Func<PlayerState> player,string exec
SessionMaintenanceExit.RequireMainMenu(frame,DateTimeOffset.UtcNow);
Progress?.Invoke("已确认游戏主菜单,正在结束客户端以加载新版插件");
await lifecycle.TerminateAsync(target,token).ConfigureAwait(false);
endedAtMenu=true;
}
finally{await recovery.EndAsync(owner).ConfigureAwait(false);}
finally{await SessionMaintenanceExit.ReleaseAfterExitAsync(()=>recovery.EndAsync(owner),()=>endedAtMenu&&game.HasExited).ConfigureAwait(false);}
using var menuDeadline=CancellationTokenSource.CreateLinkedTokenSource(token);menuDeadline.CancelAfter(TimeSpan.FromSeconds(15));
await game.WaitForExitAsync(menuDeadline.Token).ConfigureAwait(false);
}
@@ -77,7 +77,7 @@ public partial class ToolboxViewModel
using var stop=CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);automaticTradeStop=stop;RunActive=true;HasError=false;
try
{
var executable=PluginGameExecutable;var bootstrap=new WindowsTradingBootstrap(()=>runtime.Player,executable,Path.Combine(AppContext.BaseDirectory,"PluginPackage"));
var executable=PluginGameExecutable;var bootstrap=new WindowsTradingBootstrap(()=>runtime.Player,executable,Path.Combine(AppContext.BaseDirectory,"PluginPackage"),Path.Combine(value.WorkingDirectory,"operations"));
bootstrap.Progress+=message=>dispatcher.TryEnqueue(()=>{RunStatus="更新并恢复";RunMessage=message;runtime.Log("跑商更新",message);});
await Task.Run(()=>bootstrap.UpgradeAndEnterAsync(stop.Token),stop.Token);succeeded=true;AutomaticPluginUpdateRequired=false;
}
@@ -2,6 +2,15 @@
更新:2026-10-25(Asia/Shanghai)。最新状态以紧接下方的记录为准,后文保留历史故障及验收过程。
## 0.5.17已实际买入362t铀,程序正自动运输(2026-10-25 06:27)
- 唯一工具箱98656/r8,实际游戏97564。用户已授权的更新由工具箱在Polyakov停靠空舱后完成;原始LoadGame为06:24:52、06:25:44 Location确认原Solo/Type-9/7/752t,游戏加载器SHA-256 414981dabc0a81b448d1b590c4260180f2dfdc78480f124ee610c08201e0e2e7,对应0.5.17-dev;游戏构建与ABI未变。没有因通用危险标记停航。
- 旧0.5.16采购入口也错误要求行情含credits,06:15:24在入队前直接拒绝f6115bfbb6f64b02879388982aaf5d5d。更新前工具箱使用同一原进程/会话的操作键映射确认known=false、mutationsSent=0,且实际库存和钱包与意图提交前一致;另存submitted=false与原pending哈希,保留原意图,不伪造成功交易。跨进程/跨LoadGame、已接受键或余额不符均不能这样核对,4项回归通过。
- 新原生入口按真实行情协议将缺失钱包字段视为未提供;字段若存在仍严格检查数值与实际钱包一致。采购仍核对新鲜船舶、实际钱包/货舱、原SKU、合法性、库存、价格、资金与费用预留;成交后服务器金额和原游戏库存/钱包回调必须全部成功。正式批次的键容量与原CG批次一致为4096,不再误用旧1t验收的32个键限制,不丢弃已接受键。原26导出/ABI1和33次相关原生隔离检查通过,29项驱动回归通过。
- 06:26:23采购3ac1b6197f6344229fecd2084e874b73真实确认:362t Uranium、7241Cr/t,总成本2,621,242Cr;钱包140,340,795→137,719,553,货舱0→362,原生state3/error0、serverConfirmed=true/clientApplied=true、mutationsSent=1。当前旅程继续原来按当次可用库存规划的362t订单,752t是实际货舱上限。不得重买同批。卖出、CG新增贡献与本批净收益仍待真实确认,不能提前计入利润。
- 当前历史工作目录净28,717,654Cr,已包含此前Mawson服务10Cr和本次Polyakov服务1437Cr;新批次尚未实现利润。旧192t失败采购及人工完成证据继续分别保留。新界面将历史累计与本次启动新增收益分开,运行中取消按钮经后台原图、局部、OCR/UIA核验可见启用,没有为视觉验证点击取消。
- 正常主菜单退出后的管道释放可能因客户端已经结束返回空消息;现在只在原主菜单退出已确认且该进程确实结束时忽略通信清理错误,其他情况仍抛出,2项菜单退出回归通过。Release构建0警告0错误。标准插件构建现同时生成/打包CruiseControl,扩展二进制与私有运行文件不提交。没有定时任务、子智能体、键鼠或助手驾驶/交易指令,持续执行由工具箱自己的任务持有。
## Homer真实停靠、CG与行情核对后自动续往Polyakov(2026-10-25 06:09)
- 06:03:37真实Docked确认Homer/4210924803。接管和停靠均由工具箱程序完成,助手没有驾驶;随后CG读取比原生停靠状态早一帧,被原接口拒绝。现在仅对入队前的这一已知拒绝等待最多10秒,已接受的CG代次只轮询同一代次;报名意图不重放。3项新增回归通过,真实原始个人CG响应确认864仍有效/已报名,贡献1152。
@@ -104,11 +104,11 @@ internal static unsafe partial class Navigation
return Obj(("generation", previous), ("duplicate", true));
}
if (commerceReconciliationRequired) throw new InvalidOperationException("An uncertain order requires a fresh game session to reconcile inventory and credits; no order was retried");
if (commerceOperationKeys.Count >= (batch?4096:32) || commerceRead.state != 3 || commerceRead.kind!=0 || commerceRead.market != market || commerceRead.entity != frame.entity || commerceRead.life != frame.life || GetTickCount64() - commerceRead.observed > 60000) throw new InvalidOperationException("Read the current original market and cargo before sending an order");
if (commerceOperationKeys.Count >= CommerceKeyCapacity(formal,batch) || commerceRead.state != 3 || commerceRead.kind!=0 || commerceRead.market != market || commerceRead.entity != frame.entity || commerceRead.life != frame.life || GetTickCount64() - commerceRead.observed > 60000) throw new InvalidOperationException("Read the current original market and cargo before sending an order");
using var document = JsonDocument.Parse(commerceReadBody); var response = document.RootElement;
var quote = response.Required("commodities").Required(commodity.ToString(System.Globalization.CultureInfo.InvariantCulture));
var observed = ReadCommerceShipInventory(frame); if (!observed.verified) throw new InvalidOperationException("Actual original wallet and cargo are unavailable");
if(!response.TryGetProperty("credits",out var serverBalance)||!serverBalance.TryGetUInt64(out var serverCredits)||serverCredits!=observed.credits)
if(!QuotedWalletMatches(response,observed.credits))
throw new InvalidOperationException("Original server wallet differs from the actual client; refresh the original profile before submitting an order");
var price = quote.Required(delta > 0 ? "buyPrice" : "sellPrice").GetUInt32(); var before = CommerceInventoryCount(response, commodity);
var total = 0;
@@ -5,6 +5,11 @@ namespace XFEEDToolBox.Plugin;
internal static unsafe partial class Navigation
{
private static int CommerceKeyCapacity(bool formal,bool batch)=>formal||batch?4096:32;
private static bool QuotedWalletMatches(JsonElement response,ulong actual)
=>!response.TryGetProperty("credits",out var value)||value.ValueKind==JsonValueKind.Number&&value.TryGetUInt64(out var quoted)&"ed==actual;
private static bool QuotedWalletFixture(string body,ulong actual,bool expected)
{using var value=JsonDocument.Parse(body);return QuotedWalletMatches(value.RootElement,actual)==expected;}
private static bool FormalCommodityName(string name) => name.Length is >0 and <128
&& name.All(c => char.IsAsciiLetterOrDigit(c) || c is '_' or '-');
private static string CommerceExpectedCommodity(in CommerceReadRequest work)
@@ -23,6 +28,12 @@ internal static unsafe partial class Navigation
}
private static bool FormalCommerceFixture()
{
if(CommerceKeyCapacity(true,false)!=4096||CommerceKeyCapacity(false,true)!=4096||CommerceKeyCapacity(false,false)!=32)return false;
if(!QuotedWalletFixture("{\"inventory\":[]}",100000000,true)
||!QuotedWalletFixture("{\"credits\":100000000}",100000000,true)
||!QuotedWalletFixture("{\"credits\":99999999}",100000000,false)
||!QuotedWalletFixture("{\"credits\":null}",100000000,false)
||!QuotedWalletFixture("{\"credits\":\"100000000\"}",100000000,false))return false;
var work=new CommerceReadRequest{formal=true,capacityBound=752,kind=1,commodity=123456,delta=752,before=0,after=752,totalBefore=0,creditsBefore=100000000,price=7241};
SetCommerceCommodity(ref work,"gold");
if(!CommerceExpectedCredits(work,out var expected)||expected!=94554768||CommerceExpectedCommodity(work)!="gold")return false;