using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using System.Text.Json;
using System.Diagnostics;
namespace XFEEDToolBox.Core.Galaxy;
/// <summary>Bounded queries of the installed game's actual Stellar Forge catalogue.</summary>
public sealed partial class NativeGalaxyCatalog(int processId,TimeSpan? regionTimeout=null)
{
private readonly NavigationPipeClient pipe=new(processId);
private readonly SemaphoreSlim queries=new(1,1);
public int ProcessId=>processId;
private bool protocolInspected;
public double MinimumRegionRadius { get; private set; }=5;
public bool SupportsRegionCoverage { get; private set; }
public bool SupportsCancellation { get; private set; }
public bool SupportsCells { get; private set; }
public bool SupportsCatalogueProfile { get; private set; }
public bool SupportsPointOverrides { get; private set; }
public async Task InspectProtocolAsync(CancellationToken token=default)
{
if(protocolInspected)return;
var description=await pipe.CallAsync(new{op="describe"},token);
ApplyProtocolDescription(description);
}
public void ApplyProtocolDescription(JsonElement description)
{
if(description.TryGetProperty("processId",out var id)&&id.GetInt32()!=ProcessId)throw new InvalidDataException("游戏银河目录进程已变化。");
bool Flag(string name)=>description.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
SupportsRegionCoverage=Flag("galaxyRegionCoverage");SupportsCancellation=Flag("galaxyCancellation");
SupportsCells=Flag("galaxyCellQueries");
SupportsCatalogueProfile=Flag("galaxyCatalogueProfile");
SupportsPointOverrides=Flag("galaxyPointOverrides");
SupportsDustInputs=Flag("dustInputs");
SupportsRouteFilterChoices=Flag("routeFilterChoices");
MinimumRegionRadius=description.TryGetProperty("galaxyMinimumRadius",out var minimum)&&minimum.ValueKind==JsonValueKind.Number
&&minimum.TryGetDouble(out var radius)&&double.IsFinite(radius)&&radius is >=.05 and <=700?radius:5;
protocolInspected=true;
}
/// <summary>Invoked on the requesting method's synchronization context; rows are never speculative.</summary>
public event Action<GalaxyNativeRegionProgress>? RegionProgress;
public async Task<GalacticSystem> FindAsync(string name,CancellationToken token=default)
{
var found=(await QueryAsync("galaxy.lookup",name,token)).FirstOrDefault()??throw new KeyNotFoundException("游戏银河目录未找到该星系。");
if(!found.Name.Equals(name.Trim(),StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("游戏星系查询返回的名称与搜索目标不一致。");
return found;
}
public Task<IReadOnlyList<GalacticSystem>> SuggestAsync(string prefix,CancellationToken token=default)=>QueryAsync("galaxy.suggest",prefix,token);
public async Task<GalacticSystem> ResolveAddressAsync(long address,CancellationToken token=default)
{
if(address<=0||address>=1L<<55)throw new ArgumentException("游戏星系地址无效。",nameof(address));
var rows=await QueryAsync("galaxy.resolve",address.ToString(System.Globalization.CultureInfo.InvariantCulture),token);
if(rows.Count==0)throw new KeyNotFoundException("游戏银河目录尚未解析该星系地址。");
if(rows.Count!=1||rows[0].SystemAddress!=address)throw new InvalidDataException("游戏地址查询返回了不同星系,已拒绝替换书签位置。");
return rows[0];
}
public async Task<IReadOnlyList<GalacticSystem>> RegionAsync(Coordinates centre,double radius,CancellationToken token=default)
=> (await RegionPageAsync(centre,radius,token)).Systems;
public Task<GalaxyRegionResult> RegionPageAsync(Coordinates centre,double radius,CancellationToken token=default)
{
if(!centre.IsValid||!double.IsFinite(radius)||radius is <.05 or >700)throw new ArgumentException("银河视野范围无效。");
radius=Math.Max(radius,MinimumRegionRadius);
return RegionRequestAsync(centre,radius,new{op="galaxy.region",centre=new[]{centre.X,centre.Y,centre.Z},radius},null,token);
}
/// <summary>Read complete canonical cells selected by the original eight-mass descriptor, with no point-distance cut.</summary>
public Task<GalaxyRegionResult> CellsAsync(Coordinates centre,IReadOnlyList<double> massRadii,IReadOnlyList<ulong> cellAddresses,CancellationToken token=default)
{
if(protocolInspected&&!SupportsCells)throw new NotSupportedException("当前游戏插件尚不支持原质量层星区查询。");
if(centre is not{IsValid:true}||Math.Max(Math.Abs(centre.X),Math.Max(Math.Abs(centre.Y),Math.Abs(centre.Z)))>200000||
massRadii is not{Count:8}||massRadii.Any(r=>!double.IsFinite(r)||r<0||r>200000)||cellAddresses is not{Count:>=1 and <=512})
throw new ArgumentException("原质量层星区查询范围无效。");
var ids=cellAddresses.ToArray();var radii=massRadii.ToArray();
if(ids.Any(id=>id>=1UL<<55||GalaxyCellQueryPlan.CellAddress(id)!=id)||ids.Distinct().Count()!=ids.Length)
throw new ArgumentException("原星区编号无效或重复。",nameof(cellAddresses));
var request=new{op="galaxy.cells",centre=new[]{centre.X,centre.Y,centre.Z},radii,
cellAddresses=ids.Select(id=>id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray()};
return RegionRequestAsync(centre,radii.Max(),request,ids,token);
}
private async Task<GalaxyRegionResult> RegionRequestAsync(Coordinates centre,double radius,object request,IReadOnlyList<ulong>? requestedCells,CancellationToken token)
{
await queries.WaitAsync(token);
long generation=0;
using var deadline=new CancellationTokenSource(regionTimeout??TimeSpan.FromSeconds(requestedCells?.Count==1?95:35));
try
{
token.ThrowIfCancellationRequested();
// Receive the generation even if the view changes during acceptance, so
// cancellation can never leave an anonymous job occupying the game queue.
var result=await pipe.CallAsync(request,deadline.Token);
generation=result.GetProperty("generation").GetInt64();
var cellSet=requestedCells?.ToHashSet();
Coordinates? cellOrigin=null;var cellIdentities=new Dictionary<long,GalacticSystem>();
var rows=new List<GalacticSystem>();
var pending=new List<GalacticSystem>(512);
var published=new HashSet<long>();
var lastPublished=Stopwatch.GetTimestamp();var firstPublish=true;
var previousTotal=0;var emptyPages=0;var requestedPage=false;int? acceptedRowLimit=null;
while(true)
{
token.ThrowIfCancellationRequested();
if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星区查询回执已变化。");
var nativeDone=result.GetProperty("done").GetBoolean();
var nativeError=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
var available=result.TryGetProperty("total",out var totalValue)?totalValue.GetInt32():rows.Count;
var receipt=ReadCoverage(result,[],available);
if(requestedCells is null&&receipt.RowLimit!=GalaxyRegionResult.NativeRowLimit||acceptedRowLimit is{} previousLimit&&receipt.RowLimit!=previousLimit)
throw new InvalidDataException("游戏星区分页上限在查询中发生变化。");
acceptedRowLimit=receipt.RowLimit;
if(available<0||available>receipt.RowLimit||available<previousTotal)throw new InvalidDataException("游戏星区分页数量回退或超出限制。");
previousTotal=available;
if(requestedCells is not null&&(receipt.Scope!="requested-cells"||receipt.RequestedCells!=requestedCells.Count||
receipt.TotalCells!=requestedCells.Count||receipt.MinimumMassCode!=requestedCells.Min(id=>(int)(id&7))))
throw new InvalidDataException("游戏返回的原星区查询范围已变化。");
// A completed, started region can retain actual rows after a
// failed cell/timeout. Return its failed coverage for refinement.
// An unavailable service with no started cells is retryable UI failure.
if(nativeError.Length>0&&(!nativeDone||!receipt.HasCoverageEvidence||receipt.TotalCells<=0))throw new IOException(nativeError);
var page=ReadRows(result);
if(cellSet is not null)
{
if((page.Count>0||cellOrigin is not null)&&receipt.Origin is null)throw new InvalidDataException("游戏原星区数据缺少真实坐标原点。");
if(receipt.Origin is{} origin)
{
if(cellOrigin is{} previous&&(previous.X!=origin.X||previous.Y!=origin.Y||previous.Z!=origin.Z))
throw new InvalidDataException("游戏原星区分页坐标原点已变化。");
cellOrigin=origin;
}
foreach(var star in page)
{
if(star.SystemAddress is not{} address)continue;
if(cellIdentities.TryGetValue(address,out var previous)&&
(previous.Name!=star.Name||previous.Position.X!=star.Position.X||previous.Position.Y!=star.Position.Y||previous.Position.Z!=star.Position.Z))
throw new InvalidDataException("游戏原星区分页返回了冲突的星系身份。");
cellIdentities[address]=star;
}
}
if(cellSet is not null&&page.Any(star=>star.SystemAddress is not{} address||
!cellSet.Contains(GalaxyCellQueryPlan.CellAddress((ulong)address))))
throw new InvalidDataException("游戏返回了未请求星区内的恒星。");
if(rows.Count+page.Count>available)throw new InvalidDataException("游戏星区分页返回数量超出回执。");
if(page.Count==0&&requestedPage&&rows.Count<available)
{
// Accept a metadata-only legacy reply once, then require
// the requested stable append page to make progress.
if(++emptyPages>1)throw new InvalidDataException("游戏星区分页返回数量不完整。");
}
else if(page.Count>0)emptyPages=0;
rows.AddRange(page);
foreach(var star in page)if(star.SystemAddress is {} address&&published.Add(address))pending.Add(star);
var finished=nativeDone&&rows.Count==available;
var coverage=receipt with{Complete=finished&&nativeError.Length==0&&receipt.Complete};
var publishedFinished=false;
var elapsed=Stopwatch.GetElapsedTime(lastPublished).TotalMilliseconds;
if(pending.Count>0&&(firstPublish||finished||pending.Count>=512||elapsed>=100))
{
while(pending.Count>0)
{
token.ThrowIfCancellationRequested();
var count=Math.Min(512,pending.Count);var delta=pending.GetRange(0,count).ToArray();pending.RemoveRange(0,count);
RegionProgress?.Invoke(new(generation,centre,radius,delta,coverage,finished&&pending.Count==0,available,rows.Count));
publishedFinished=finished&&pending.Count==0;
// Keep intermediate batches bounded without publishing
// every 96-row pipe page into the UI.
if(!finished&&pending.Count<512)break;
}
firstPublish=false;lastPublished=Stopwatch.GetTimestamp();
}
if(finished)
{
token.ThrowIfCancellationRequested();
if(!publishedFinished)RegionProgress?.Invoke(new(generation,centre,radius,[],coverage,true,available,rows.Count));
return ReadCoverage(result,rows.DistinctBy(s=>s.SystemAddress).ToArray(),available);
}
using var pageStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
if(rows.Count==available)await Task.Delay(30,pageStop.Token);
result=await pipe.CallAsync(new{op="galaxy.result",generation,offset=rows.Count,limit=96},pageStop.Token);
requestedPage=true;
}
}
catch(OperationCanceledException)
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?35:2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
if(token.IsCancellationRequested)throw;
throw new TimeoutException("游戏星区查询超时;已保留收到的星系,请移动或缩放后重试。");
}
catch
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
throw;
}
finally{queries.Release();}
}
public static GalaxyRegionResult ReadCoverage(JsonElement result,IReadOnlyList<GalacticSystem> rows,int? returnedCount=null)
{
int Number(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.Number&&value.TryGetInt32(out var number)?number:-1;
bool Flag(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
var mass=Number("minimumMassCode");var processed=Number("processedCells");var total=Number("totalCells");var failed=Number("failedCells");
if(mass is < -1 or >7||processed< -1||total< -1||processed>total&&total>=0||failed< -1)
throw new InvalidDataException("游戏星区覆盖回执无效。");
var error=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
var scope=result.TryGetProperty("scope",out var scopeValue)&&scopeValue.ValueKind==JsonValueKind.String?scopeValue.GetString()??"":"";
var requested=Number("requestedCells");
if(scope is not("" or "region-sphere" or "requested-cells")||requested is < -1 or >512||
scope=="requested-cells"&&(requested<1||total!=requested))throw new InvalidDataException("游戏原星区覆盖范围无效。");
var rowLimit=GalaxyRegionResult.NativeRowLimit;
if(result.TryGetProperty("rowLimit",out var rowLimitValue)&&
(rowLimitValue.ValueKind!=JsonValueKind.Number||!rowLimitValue.TryGetInt32(out rowLimit)))
throw new InvalidDataException("游戏原星区分页上限无效。");
if(rowLimit!=GalaxyRegionResult.NativeRowLimit&&!(rowLimit==GalaxyRegionResult.NativeSingleCellRowLimit&&scope=="requested-cells"&&requested==1))
throw new InvalidDataException("游戏原星区分页上限无效。");
var capReached=(returnedCount??rows.Count)>=rowLimit&&rowLimit==GalaxyRegionResult.NativeRowLimit;
Coordinates? origin=null;
if(result.TryGetProperty("origin",out var originValue)&&originValue.ValueKind!=JsonValueKind.Null)
{
if(originValue.ValueKind!=JsonValueKind.Array||originValue.GetArrayLength()!=3)throw new InvalidDataException("游戏银河坐标原点无效。");
origin=new(originValue[0].GetDouble(),originValue[1].GetDouble(),originValue[2].GetDouble());
if(!origin.IsValid)throw new InvalidDataException("游戏银河坐标原点无效。");
}
return new(rows,mass,processed,total,Flag("truncated")||capReached,
Flag("complete")&&error.Length==0,Math.Max(error.Length>0?1:0,failed)){Error=error,Scope=scope,RequestedCells=requested,Origin=origin,RowLimit=rowLimit};
}
private async Task RetireCancelledQueryAsync(long generation,CancellationToken deadline)
{
// Older plugins reject galaxy.cancel. Drain that one accepted job rather
// than poisoning the next request with 'query already pending'. The new
// protocol releases it on the owning game thread at the next sample.
try{if(!protocolInspected||SupportsCancellation)await pipe.CallAsync(new{op="galaxy.cancel",generation},deadline);}catch(InvalidOperationException){}
catch(OperationCanceledException){return;}catch(IOException){return;}
try
{
while(!deadline.IsCancellationRequested)
{
var result=await pipe.CallAsync(new{op="galaxy.result",generation,limit=0},deadline);
if(result.GetProperty("generation").GetInt64()!=generation||result.GetProperty("done").GetBoolean())return;
await Task.Delay(30,deadline);
}
}
catch(OperationCanceledException){}catch(InvalidOperationException){}catch(IOException){}
}
private static IReadOnlyList<GalacticSystem> ReadRows(System.Text.Json.JsonElement result)
{
var rows=new List<GalacticSystem>();
foreach(var item in result.GetProperty("items").EnumerateArray())
{
if(!long.TryParse(item.GetProperty("systemAddress").GetString(),out var address)||address<=0||address>=1L<<55)throw new InvalidDataException("游戏星系地址无效。");
var p=item.GetProperty("position");var point=new Coordinates(p[0].GetDouble(),p[1].GetDouble(),p[2].GetDouble());
var name=item.GetProperty("name").GetString()??"";
if(!point.IsValid||name.Length is <1 or >159)throw new InvalidDataException("游戏星系名称或坐标无效。");
GalaxyOriginalStellarAppearance? appearance=null;
if(item.TryGetProperty("originalAppearance",out var a)&&a.ValueKind!=JsonValueKind.Null)
{
if(a.ValueKind!=JsonValueKind.Object)throw new InvalidDataException("原恒星外观字段无效。");
var type=a.GetProperty("typeRgba").GetUInt32();uint? realistic=a.GetProperty("realisticRgba").ValueKind==JsonValueKind.Null?null:a.GetProperty("realisticRgba").GetUInt32();
float? blend=a.GetProperty("colourBlend").ValueKind==JsonValueKind.Null?null:a.GetProperty("colourBlend").GetSingle();
var raw=a.GetProperty("rawScale").GetUInt32();var classification=a.GetProperty("classification").GetUInt16();
var size=a.GetProperty("size").GetSingle();var small=a.GetProperty("smallSize").GetSingle();var setting=a.GetProperty("smallSizeActive");
bool? active=setting.ValueKind==JsonValueKind.Null?null:setting.ValueKind is JsonValueKind.True or JsonValueKind.False?setting.GetBoolean():throw new InvalidDataException("原恒星尺寸设置无效。");
if(!float.IsFinite(size)||!float.IsFinite(small)||size is <0 or >4||small is <0 or >4||blend is {} value&&(!float.IsFinite(value)||value is <0 or >1))throw new InvalidDataException("原恒星外观数值无效。");
bool? membership=null;int? membershipMode=null;int? routeView=null;
if(a.TryGetProperty("navRouteMembership",out var member)&&member.ValueKind!=JsonValueKind.Null)
{if(member.ValueKind is not(JsonValueKind.True or JsonValueKind.False))throw new InvalidDataException("原导航成员标记无效。");membership=member.GetBoolean();}
if(a.TryGetProperty("navRouteMembershipMode",out var rm)&&rm.ValueKind!=JsonValueKind.Null)
{if(!rm.TryGetInt32(out var routeMode)||routeMode is <0 or >1)throw new InvalidDataException("原导航成员模式无效。");membershipMode=routeMode;}
if(a.TryGetProperty("navRouteViewMode",out var rv)&&rv.ValueKind!=JsonValueKind.Null)
{if(!rv.TryGetInt32(out var routeViewValue))throw new InvalidDataException("原导航视图模式无效。");routeView=routeViewValue;}
if(membership is not null&&(membershipMode is null||routeView is null)||routeView is{} originalRouteView&&membershipMode is{} originalMemberMode&&(originalRouteView==1?1:0)!=originalMemberMode)
throw new InvalidDataException("原导航成员上下文不匹配。");
appearance=new(type,realistic,blend,raw,classification,size,small,active)
{NavRouteMembership=membership,NavRouteMembershipMode=membershipMode,NavRouteViewMode=routeView};
}
rows.Add(new(name,point,address,item.GetProperty("starClass").GetString()??"","游戏银河目录"){OriginalAppearance=appearance});
}
return rows;
}
private async Task<IReadOnlyList<GalacticSystem>> QueryAsync(string operation,string query,CancellationToken token)
{
query=query.Trim();if(query.Length is <1 or >128)throw new ArgumentException("星系名称长度无效。");
await queries.WaitAsync(token);
long generation=0;
using var deadline=new CancellationTokenSource(TimeSpan.FromSeconds(12));
try
{
token.ThrowIfCancellationRequested();
// Keep acceptance independent so a changed search term can retire
// its own generation instead of leaving an anonymous native job.
var result=operation=="galaxy.resolve"
?await pipe.CallAsync(new{op=operation,systemAddress=query},deadline.Token)
:await pipe.CallAsync(new{op=operation,query},deadline.Token);
generation=result.GetProperty("generation").GetInt64();
while(!result.GetProperty("done").GetBoolean())
{
token.ThrowIfCancellationRequested();
using var pollStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
await Task.Delay(30,pollStop.Token);
result=await pipe.CallAsync(new{op="galaxy.result",generation},pollStop.Token);
if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星系查询回执已变化。");
}
token.ThrowIfCancellationRequested();
if(result.GetProperty("error").GetString() is {Length:>0} error)throw new IOException(error);
return ReadRows(result).Take(20).DistinctBy(s=>s.SystemAddress).ToArray();
}
catch(OperationCanceledException)
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?12:2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
if(token.IsCancellationRequested)throw;
throw new TimeoutException("游戏星系查询超时,请重新搜索。");
}
finally{queries.Release();}
}
}
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using System.Text.Json;
using System.Diagnostics;
namespace XFEEDToolBox.Core.Galaxy;
/// <summary>Bounded queries of the installed game's actual Stellar Forge catalogue.</summary>
public sealed partial class NativeGalaxyCatalog(int processId,TimeSpan? regionTimeout=null)
{
private readonly NavigationPipeClient pipe=new(processId);
private readonly SemaphoreSlim queries=new(1,1);
public int ProcessId=>processId;
private bool protocolInspected;
public double MinimumRegionRadius { get; private set; }=5;
public bool SupportsRegionCoverage { get; private set; }
public bool SupportsCancellation { get; private set; }
public bool SupportsCells { get; private set; }
public bool SupportsCatalogueProfile { get; private set; }
public bool SupportsPointOverrides { get; private set; }
public async Task InspectProtocolAsync(CancellationToken token=default)
{
if(protocolInspected)return;
var description=await pipe.CallAsync(new{op="describe"},token);
ApplyProtocolDescription(description);
}
public void ApplyProtocolDescription(JsonElement description)
{
if(description.TryGetProperty("processId",out var id)&&id.GetInt32()!=ProcessId)throw new InvalidDataException("游戏银河目录进程已变化。");
bool Flag(string name)=>description.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
SupportsRegionCoverage=Flag("galaxyRegionCoverage");SupportsCancellation=Flag("galaxyCancellation");
SupportsCells=Flag("galaxyCellQueries");
SupportsCatalogueProfile=Flag("galaxyCatalogueProfile");
SupportsPointOverrides=Flag("galaxyPointOverrides");
SupportsDustInputs=Flag("dustInputs");
SupportsRouteFilterChoices=Flag("routeFilterChoices");
MinimumRegionRadius=description.TryGetProperty("galaxyMinimumRadius",out var minimum)&&minimum.ValueKind==JsonValueKind.Number
&&minimum.TryGetDouble(out var radius)&&double.IsFinite(radius)&&radius is >=.05 and <=700?radius:5;
protocolInspected=true;
}
/// <summary>Invoked on the requesting method's synchronization context; rows are never speculative.</summary>
public event Action<GalaxyNativeRegionProgress>? RegionProgress;
public async Task<GalacticSystem> FindAsync(string name,CancellationToken token=default)
{
var found=(await QueryAsync("galaxy.lookup",name,token)).FirstOrDefault()??throw new KeyNotFoundException("游戏银河目录未找到该星系。");
if(!found.Name.Equals(name.Trim(),StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("游戏星系查询返回的名称与搜索目标不一致。");
return found;
}
public Task<IReadOnlyList<GalacticSystem>> SuggestAsync(string prefix,CancellationToken token=default)=>QueryAsync("galaxy.suggest",prefix,token);
public async Task<GalacticSystem> ResolveAddressAsync(long address,CancellationToken token=default)
{
if(address<=0||address>=1L<<55)throw new ArgumentException("游戏星系地址无效。",nameof(address));
var rows=await QueryAsync("galaxy.resolve",address.ToString(System.Globalization.CultureInfo.InvariantCulture),token);
if(rows.Count==0)throw new KeyNotFoundException("游戏银河目录尚未解析该星系地址。");
if(rows.Count!=1||rows[0].SystemAddress!=address)throw new InvalidDataException("游戏地址查询返回了不同星系,已拒绝替换书签位置。");
return rows[0];
}
public async Task<IReadOnlyList<GalacticSystem>> RegionAsync(Coordinates centre,double radius,CancellationToken token=default)
=> (await RegionPageAsync(centre,radius,token)).Systems;
public Task<GalaxyRegionResult> RegionPageAsync(Coordinates centre,double radius,CancellationToken token=default)
{
if(!centre.IsValid||!double.IsFinite(radius)||radius is <.05 or >700)throw new ArgumentException("银河视野范围无效。");
radius=Math.Max(radius,MinimumRegionRadius);
return RegionRequestAsync(centre,radius,new{op="galaxy.region",centre=new[]{centre.X,centre.Y,centre.Z},radius},null,token);
}
/// <summary>Read complete canonical cells selected by the original eight-mass descriptor, with no point-distance cut.</summary>
public Task<GalaxyRegionResult> CellsAsync(Coordinates centre,IReadOnlyList<double> massRadii,IReadOnlyList<ulong> cellAddresses,CancellationToken token=default)
{
if(protocolInspected&&!SupportsCells)throw new NotSupportedException("当前游戏插件尚不支持原质量层星区查询。");
if(centre is not{IsValid:true}||Math.Max(Math.Abs(centre.X),Math.Max(Math.Abs(centre.Y),Math.Abs(centre.Z)))>200000||
massRadii is not{Count:8}||massRadii.Any(r=>!double.IsFinite(r)||r<0||r>200000)||cellAddresses is not{Count:>=1 and <=512})
throw new ArgumentException("原质量层星区查询范围无效。");
var ids=cellAddresses.ToArray();var radii=massRadii.ToArray();
if(ids.Any(id=>id>=1UL<<55||GalaxyCellQueryPlan.CellAddress(id)!=id)||ids.Distinct().Count()!=ids.Length)
throw new ArgumentException("原星区编号无效或重复。",nameof(cellAddresses));
var request=new{op="galaxy.cells",centre=new[]{centre.X,centre.Y,centre.Z},radii,
cellAddresses=ids.Select(id=>id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray()};
return RegionRequestAsync(centre,radii.Max(),request,ids,token);
}
private async Task<GalaxyRegionResult> RegionRequestAsync(Coordinates centre,double radius,object request,IReadOnlyList<ulong>? requestedCells,CancellationToken token)
{
await queries.WaitAsync(token);
long generation=0;
using var deadline=new CancellationTokenSource(regionTimeout??TimeSpan.FromSeconds(requestedCells?.Count==1?95:35));
try
{
token.ThrowIfCancellationRequested();
// Receive the generation even if the view changes during acceptance, so
// cancellation can never leave an anonymous job occupying the game queue.
var result=await pipe.CallAsync(request,deadline.Token);
generation=result.GetProperty("generation").GetInt64();
var cellSet=requestedCells?.ToHashSet();
Coordinates? cellOrigin=null;var cellIdentities=new Dictionary<long,GalacticSystem>();
var rows=new List<GalacticSystem>();
var pending=new List<GalacticSystem>(512);
var published=new HashSet<long>();
var lastPublished=Stopwatch.GetTimestamp();var firstPublish=true;
var previousTotal=0;var emptyPages=0;var requestedPage=false;int? acceptedRowLimit=null;
while(true)
{
token.ThrowIfCancellationRequested();
if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星区查询回执已变化。");
var nativeDone=result.GetProperty("done").GetBoolean();
var nativeError=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
var available=result.TryGetProperty("total",out var totalValue)?totalValue.GetInt32():rows.Count;
var receipt=ReadCoverage(result,[],available);
if(requestedCells is null&&receipt.RowLimit!=GalaxyRegionResult.NativeRowLimit||acceptedRowLimit is{} previousLimit&&receipt.RowLimit!=previousLimit)
throw new InvalidDataException("游戏星区分页上限在查询中发生变化。");
acceptedRowLimit=receipt.RowLimit;
if(available<0||available>receipt.RowLimit||available<previousTotal)throw new InvalidDataException("游戏星区分页数量回退或超出限制。");
previousTotal=available;
if(requestedCells is not null&&(receipt.Scope!="requested-cells"||receipt.RequestedCells!=requestedCells.Count||
receipt.TotalCells!=requestedCells.Count||receipt.MinimumMassCode!=requestedCells.Min(id=>(int)(id&7))))
throw new InvalidDataException("游戏返回的原星区查询范围已变化。");
// A completed, started region can retain actual rows after a
// failed cell/timeout. Return its failed coverage for refinement.
// An unavailable service with no started cells is retryable UI failure.
if(nativeError.Length>0&&(!nativeDone||!receipt.HasCoverageEvidence||receipt.TotalCells<=0))throw new IOException(nativeError);
var page=ReadRows(result);
if(cellSet is not null)
{
if((page.Count>0||cellOrigin is not null)&&receipt.Origin is null)throw new InvalidDataException("游戏原星区数据缺少真实坐标原点。");
if(receipt.Origin is{} origin)
{
if(cellOrigin is{} previous&&(previous.X!=origin.X||previous.Y!=origin.Y||previous.Z!=origin.Z))
throw new InvalidDataException("游戏原星区分页坐标原点已变化。");
cellOrigin=origin;
}
foreach(var star in page)
{
if(star.SystemAddress is not{} address)continue;
if(cellIdentities.TryGetValue(address,out var previous)&&
(previous.Name!=star.Name||previous.Position.X!=star.Position.X||previous.Position.Y!=star.Position.Y||previous.Position.Z!=star.Position.Z))
throw new InvalidDataException("游戏原星区分页返回了冲突的星系身份。");
cellIdentities[address]=star;
}
}
if(cellSet is not null&&page.Any(star=>star.SystemAddress is not{} address||
!cellSet.Contains(GalaxyCellQueryPlan.CellAddress((ulong)address))))
throw new InvalidDataException("游戏返回了未请求星区内的恒星。");
if(rows.Count+page.Count>available)throw new InvalidDataException("游戏星区分页返回数量超出回执。");
if(page.Count==0&&requestedPage&&rows.Count<available)
{
// Accept a metadata-only legacy reply once, then require
// the requested stable append page to make progress.
if(++emptyPages>1)throw new InvalidDataException("游戏星区分页返回数量不完整。");
}
else if(page.Count>0)emptyPages=0;
rows.AddRange(page);
foreach(var star in page)if(star.SystemAddress is {} address&&published.Add(address))pending.Add(star);
var finished=nativeDone&&rows.Count==available;
var coverage=receipt with{Complete=finished&&nativeError.Length==0&&receipt.Complete};
var publishedFinished=false;
var elapsed=Stopwatch.GetElapsedTime(lastPublished).TotalMilliseconds;
if(pending.Count>0&&(firstPublish||finished||pending.Count>=512||elapsed>=100))
{
while(pending.Count>0)
{
token.ThrowIfCancellationRequested();
var count=Math.Min(512,pending.Count);var delta=pending.GetRange(0,count).ToArray();pending.RemoveRange(0,count);
RegionProgress?.Invoke(new(generation,centre,radius,delta,coverage,finished&&pending.Count==0,available,rows.Count));
publishedFinished=finished&&pending.Count==0;
// Keep intermediate batches bounded without publishing
// every 96-row pipe page into the UI.
if(!finished&&pending.Count<512)break;
}
firstPublish=false;lastPublished=Stopwatch.GetTimestamp();
}
if(finished)
{
token.ThrowIfCancellationRequested();
if(!publishedFinished)RegionProgress?.Invoke(new(generation,centre,radius,[],coverage,true,available,rows.Count));
return ReadCoverage(result,rows.DistinctBy(s=>s.SystemAddress).ToArray(),available);
}
using var pageStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
if(rows.Count==available)await Task.Delay(30,pageStop.Token);
result=await pipe.CallAsync(new{op="galaxy.result",generation,offset=rows.Count,limit=96},pageStop.Token);
requestedPage=true;
}
}
catch(OperationCanceledException)
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?35:2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
if(token.IsCancellationRequested)throw;
throw new TimeoutException("游戏星区查询超时;已保留收到的星系,请移动或缩放后重试。");
}
catch
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
throw;
}
finally{queries.Release();}
}
public static GalaxyRegionResult ReadCoverage(JsonElement result,IReadOnlyList<GalacticSystem> rows,int? returnedCount=null)
{
int Number(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.Number&&value.TryGetInt32(out var number)?number:-1;
bool Flag(string name)=>result.TryGetProperty(name,out var value)&&value.ValueKind==JsonValueKind.True;
var mass=Number("minimumMassCode");var processed=Number("processedCells");var total=Number("totalCells");var failed=Number("failedCells");
if(mass is < -1 or >7||processed< -1||total< -1||processed>total&&total>=0||failed< -1)
throw new InvalidDataException("游戏星区覆盖回执无效。");
var error=result.TryGetProperty("error",out var errorValue)&&errorValue.ValueKind==JsonValueKind.String?errorValue.GetString()??"":"";
var scope=result.TryGetProperty("scope",out var scopeValue)&&scopeValue.ValueKind==JsonValueKind.String?scopeValue.GetString()??"":"";
var requested=Number("requestedCells");
if(scope is not("" or "region-sphere" or "requested-cells")||requested is < -1 or >512||
scope=="requested-cells"&&(requested<1||total!=requested))throw new InvalidDataException("游戏原星区覆盖范围无效。");
var rowLimit=GalaxyRegionResult.NativeRowLimit;
if(result.TryGetProperty("rowLimit",out var rowLimitValue)&&
(rowLimitValue.ValueKind!=JsonValueKind.Number||!rowLimitValue.TryGetInt32(out rowLimit)))
throw new InvalidDataException("游戏原星区分页上限无效。");
if(rowLimit!=GalaxyRegionResult.NativeRowLimit&&!(rowLimit==GalaxyRegionResult.NativeSingleCellRowLimit&&scope=="requested-cells"&&requested==1))
throw new InvalidDataException("游戏原星区分页上限无效。");
var capReached=(returnedCount??rows.Count)>=rowLimit&&rowLimit==GalaxyRegionResult.NativeRowLimit;
Coordinates? origin=null;
if(result.TryGetProperty("origin",out var originValue)&&originValue.ValueKind!=JsonValueKind.Null)
{
if(originValue.ValueKind!=JsonValueKind.Array||originValue.GetArrayLength()!=3)throw new InvalidDataException("游戏银河坐标原点无效。");
origin=new(originValue[0].GetDouble(),originValue[1].GetDouble(),originValue[2].GetDouble());
if(!origin.IsValid)throw new InvalidDataException("游戏银河坐标原点无效。");
}
return new(rows,mass,processed,total,Flag("truncated")||capReached,
Flag("complete")&&error.Length==0,Math.Max(error.Length>0?1:0,failed)){Error=error,Scope=scope,RequestedCells=requested,Origin=origin,RowLimit=rowLimit};
}
private async Task RetireCancelledQueryAsync(long generation,CancellationToken deadline)
{
// Older plugins reject galaxy.cancel. Drain that one accepted job rather
// than poisoning the next request with 'query already pending'. The new
// protocol releases it on the owning game thread at the next sample.
try{if(!protocolInspected||SupportsCancellation)await pipe.CallAsync(new{op="galaxy.cancel",generation},deadline);}catch(InvalidOperationException){}
catch(OperationCanceledException){return;}catch(IOException){return;}
try
{
while(!deadline.IsCancellationRequested)
{
var result=await pipe.CallAsync(new{op="galaxy.result",generation,limit=0},deadline);
if(result.GetProperty("generation").GetInt64()!=generation||result.GetProperty("done").GetBoolean())return;
await Task.Delay(30,deadline);
}
}
catch(OperationCanceledException){}catch(InvalidOperationException){}catch(IOException){}
}
private static IReadOnlyList<GalacticSystem> ReadRows(System.Text.Json.JsonElement result)
{
var rows=new List<GalacticSystem>();
foreach(var item in result.GetProperty("items").EnumerateArray())
{
if(!long.TryParse(item.GetProperty("systemAddress").GetString(),out var address)||address<=0||address>=1L<<55)throw new InvalidDataException("游戏星系地址无效。");
var p=item.GetProperty("position");var point=new Coordinates(p[0].GetDouble(),p[1].GetDouble(),p[2].GetDouble());
var name=item.GetProperty("name").GetString()??"";
if(!point.IsValid||name.Length is <1 or >159)throw new InvalidDataException("游戏星系名称或坐标无效。");
GalaxyOriginalStellarAppearance? appearance=null;
if(item.TryGetProperty("originalAppearance",out var a)&&a.ValueKind!=JsonValueKind.Null)
{
if(a.ValueKind!=JsonValueKind.Object)throw new InvalidDataException("原恒星外观字段无效。");
var type=a.GetProperty("typeRgba").GetUInt32();uint? realistic=a.GetProperty("realisticRgba").ValueKind==JsonValueKind.Null?null:a.GetProperty("realisticRgba").GetUInt32();
float? blend=a.GetProperty("colourBlend").ValueKind==JsonValueKind.Null?null:a.GetProperty("colourBlend").GetSingle();
var raw=a.GetProperty("rawScale").GetUInt32();var classification=a.GetProperty("classification").GetUInt16();
var size=a.GetProperty("size").GetSingle();var small=a.GetProperty("smallSize").GetSingle();var setting=a.GetProperty("smallSizeActive");
bool? active=setting.ValueKind==JsonValueKind.Null?null:setting.ValueKind is JsonValueKind.True or JsonValueKind.False?setting.GetBoolean():throw new InvalidDataException("原恒星尺寸设置无效。");
if(!float.IsFinite(size)||!float.IsFinite(small)||size is <0 or >4||small is <0 or >4||blend is {} value&&(!float.IsFinite(value)||value is <0 or >1))throw new InvalidDataException("原恒星外观数值无效。");
bool? membership=null;int? membershipMode=null;int? routeView=null;
if(a.TryGetProperty("navRouteMembership",out var member)&&member.ValueKind!=JsonValueKind.Null)
{if(member.ValueKind is not(JsonValueKind.True or JsonValueKind.False))throw new InvalidDataException("原导航成员标记无效。");membership=member.GetBoolean();}
if(a.TryGetProperty("navRouteMembershipMode",out var rm)&&rm.ValueKind!=JsonValueKind.Null)
{if(!rm.TryGetInt32(out var routeMode)||routeMode is <0 or >1)throw new InvalidDataException("原导航成员模式无效。");membershipMode=routeMode;}
if(a.TryGetProperty("navRouteViewMode",out var rv)&&rv.ValueKind!=JsonValueKind.Null)
{if(!rv.TryGetInt32(out var routeViewValue))throw new InvalidDataException("原导航视图模式无效。");routeView=routeViewValue;}
if(membership is not null&&(membershipMode is null||routeView is null)||routeView is{} originalRouteView&&membershipMode is{} originalMemberMode&&(originalRouteView==1?1:0)!=originalMemberMode)
throw new InvalidDataException("原导航成员上下文不匹配。");
appearance=new(type,realistic,blend,raw,classification,size,small,active)
{NavRouteMembership=membership,NavRouteMembershipMode=membershipMode,NavRouteViewMode=routeView};
}
rows.Add(new(name,point,address,item.GetProperty("starClass").GetString()??"","游戏银河目录"){OriginalAppearance=appearance});
}
return rows;
}
private async Task<IReadOnlyList<GalacticSystem>> QueryAsync(string operation,string query,CancellationToken token)
{
query=query.Trim();if(query.Length is <1 or >128)throw new ArgumentException("星系名称长度无效。");
await queries.WaitAsync(token);
long generation=0;
using var deadline=new CancellationTokenSource(TimeSpan.FromSeconds(12));
try
{
token.ThrowIfCancellationRequested();
// Keep acceptance independent so a changed search term can retire
// its own generation instead of leaving an anonymous native job.
var result=operation=="galaxy.resolve"
?await pipe.CallAsync(new{op=operation,systemAddress=query},deadline.Token)
:await pipe.CallAsync(new{op=operation,query},deadline.Token);
generation=result.GetProperty("generation").GetInt64();
while(!result.GetProperty("done").GetBoolean())
{
token.ThrowIfCancellationRequested();
using var pollStop=CancellationTokenSource.CreateLinkedTokenSource(token,deadline.Token);
await Task.Delay(30,pollStop.Token);
result=await pipe.CallAsync(new{op="galaxy.result",generation},pollStop.Token);
if(result.GetProperty("generation").GetInt64()!=generation)throw new InvalidDataException("游戏星系查询回执已变化。");
}
token.ThrowIfCancellationRequested();
if(result.GetProperty("error").GetString() is {Length:>0} error)throw new IOException(error);
return ReadRows(result).Take(20).DistinctBy(s=>s.SystemAddress).ToArray();
}
catch(OperationCanceledException)
{
using var cleanup=new CancellationTokenSource(TimeSpan.FromSeconds(protocolInspected&&!SupportsCancellation?12:2));
if(generation!=0)await RetireCancelledQueryAsync(generation,cleanup.Token);
if(token.IsCancellationRequested)throw;
throw new TimeoutException("游戏星系查询超时,请重新搜索。");
}
finally{queries.Release();}
}
}