using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Plugins;
namespace XFEEDToolBox.Core.Navigation;
public sealed record NativeCruiseControlCapabilities(bool Available,bool Qualified,string Service,int OwningThread);
/// <summary>Program-owned SCO policy and bounded original-command receipts. Never steers or selects a target.</summary>
public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
{
public const string Service=CruiseControlProtocol.Service;
private readonly NavigationPipeClient pipe=new(processId,Service);
private readonly CruiseOverchargeSampleHistory history=new(gameSha256);
private readonly CruiseOverchargePolicy policy=new();
private ulong entity,world;
private ulong boundInterface,originalStartSelection;
private long originalStartAt;
private bool leaseRequested,originalStartWitness;
private string owner="";
private string boundLoadoutFingerprint="";
private string? pendingStopId,lastStopIdentity,originalStartRequestId;
private DateTimeOffset nativeStartRefusedUntil;
private SystemFlightFrame? applicationFrame;
private SystemFlightDecision? applicationDecision;
private DateTimeOffset applicationDeadline;
private string applicationStage="",applicationDetail="";
public JsonElement? LastStatus{get;private set;}
public CruiseOverchargeObservation? LastObservation{get;private set;}
public CruiseOverchargeDecision? LastDecision{get;private set;}
public event Action<JsonElement>? Observed;
public event Action<JsonElement>? OriginalReceipt;
public event Action<JsonElement>? OriginalStopReceipt;
/// <summary>Only arms a bounded strategy trial; does not enable SCO, steer, select or start navigation.</summary>
public Task<JsonElement> ConfigureExitEstimateTrialAsync(ulong shipEntity,ulong referenceWorld,DateTimeOffset expires,int maximumBursts,CancellationToken token=default)
=>pipe.CallAsync(new{op="sco.exit.configure",mode="trial",entityId=shipEntity,referenceWorldAddress=referenceWorld,
expiresAtUnixMilliseconds=expires.ToUnixTimeMilliseconds(),maximumBursts},token);
public Task<JsonElement> UseShadowExitEstimateAsync(CancellationToken token=default)=>pipe.CallAsync(new{op="sco.exit.configure",mode="shadow"},token);
public Task<JsonElement> PromoteExitEstimateProductionAsync(ulong shipEntity,ulong referenceWorld,DateTimeOffset expires,CancellationToken token=default)
=>pipe.CallAsync(new{op="sco.exit.configure",mode="production",entityId=shipEntity,referenceWorldAddress=referenceWorld,
expiresAtUnixMilliseconds=expires.ToUnixTimeMilliseconds()},token);
private static bool Flag(JsonElement value,string key)=>value.TryGetProperty(key,out var found)&&found.ValueKind==JsonValueKind.True;
private static ulong Identity(JsonElement value,string key)=>ulong.Parse(value.GetProperty(key).GetString()!,CultureInfo.InvariantCulture);
private static bool Key(JsonElement value,string key)=>value.TryGetProperty(key,out var item)&&item.ValueKind==JsonValueKind.String
&&item.GetString() is {Length:32} text&&text.All(c=>c is >= '0' and <= '9' or >= 'a' and <= 'f');
private void CheckIdentity(JsonElement result)
{
if(result.GetProperty("schema").GetInt32()!=1||result.GetProperty("processId").GetInt32()!=processId
||result.GetProperty("gameSha256").GetString()!=gameSha256||result.GetProperty("service").GetString()!=Service
||!Flag(result,"overchargeControl")||!Flag(result,"enabled")||!Flag(result,"originalFuelRangeObservations")
||!Flag(result,"nativeStopReceipts")||!Flag(result,"componentThermalModel")||!Flag(result,"expiringOriginalControl")||result.GetProperty("controlTelemetrySchema").GetInt32()!=1
||result.GetProperty("owningThread").GetInt32()<=0)throw new InvalidDataException("Original SCO controller does not match this client.");
if(Flag(result,"overchargeControlQualified"))
{
if(!result.TryGetProperty("qualifiedCycle",out var cycle)||cycle.ValueKind!=JsonValueKind.Object
||!Key(cycle,"owner")||!Key(cycle,"startRequestId")||!Key(cycle,"releaseRequestId")
||cycle.GetProperty("selectionGeneration").GetUInt64()==0||Identity(cycle,"entityId")==0||Identity(cycle,"referenceWorldAddress")==0
||cycle.GetProperty("activeSamples").GetInt32()<5)throw new InvalidDataException("Original SCO qualification has no matching live cycle.");
var enabledAt=cycle.GetProperty("enableAtUnixMilliseconds").GetUInt64();var activeAt=cycle.GetProperty("lastActiveAtUnixMilliseconds").GetUInt64();
var stopAt=cycle.GetProperty("originalStopAtUnixMilliseconds").GetUInt64();var offAt=cycle.GetProperty("offAtUnixMilliseconds").GetUInt64();
if(enabledAt==0||activeAt<enabledAt||stopAt<activeAt||offAt<=stopAt||offAt>(ulong)DateTimeOffset.UtcNow.AddMilliseconds(100).ToUnixTimeMilliseconds())
throw new InvalidDataException("Original SCO qualification timestamps do not establish enable, Active, stop and subsequent Off.");
}
}
private void Capture(JsonElement result)
{
CheckIdentity(result);LastStatus=result.Clone();
if(result.TryGetProperty("lastOriginalStop",out var stop)&&stop.ValueKind==JsonValueKind.Object&&stop.GetProperty("owner").GetString()==owner)
{
var id=stop.GetRawText();if(id!=lastStopIdentity){lastStopIdentity=id;OriginalStopReceipt?.Invoke(stop.Clone());}
}
// A real Off sample in this response follows its original stop receipt.
var traced=JsonNode.Parse(result.GetRawText())!.AsObject();
traced["applicationControl"]=JsonSerializer.SerializeToNode(new{stage=applicationStage,detail=applicationDetail,
sequence=applicationFrame?.Sequence,flightObservedAt=applicationFrame?.ObservedAt,deadline=applicationDeadline,
phase=applicationDecision?.Phase.ToString(),actions=applicationDecision?.Actions.ToString(),
useOvercharge=LastDecision?.UseOvercharge,policyReason=LastDecision?.Reason.ToString()});
Observed?.Invoke(JsonSerializer.SerializeToElement(traced));
}
public async Task<NativeCruiseControlCapabilities> InspectAsync(CancellationToken token)
{
var result=await pipe.CallAsync(new{op="describe"},token);CheckIdentity(result);
return new(true,Flag(result,"overchargeControlQualified"),Service,result.GetProperty("owningThread").GetInt32());
}
/// <summary>Read-only proof for the local navigator; does not consume or advance command sample history.</summary>
public async Task<DateTimeOffset?> ReadThermalControlExpiryAsync(SystemFlightFrame flight,CancellationToken token)
{
if(flight.Mode!=LocalFlightMode.Supercruise||flight.Assists.Supercruise
||flight.SupercruiseAssist is NativeAssistState.Requested or NativeAssistState.Aligning or NativeAssistState.Engaged
||!flight.InGame||flight.PlayerOverride||flight.ExternalThreat||flight.ShipEntity==0||flight.ReferenceWorld==0
||flight.GameSha256!=gameSha256||flight.HeatPercent is not >=0 or >=100)return null;
try
{
var status=await pipe.CallAsync(new{op="read"},token);CheckIdentity(status);
if(!Flag(status,"sampleReady")||!status.TryGetProperty("sample",out var sample)||sample.ValueKind!=JsonValueKind.Object
||!Flag(sample,"verified")||!Flag(sample,"inSupercruise")||!Flag(sample,"stableFsd")
||Identity(sample,"entityId")!=flight.ShipEntity||Identity(sample,"referenceWorldAddress")!=flight.ReferenceWorld
||sample.GetProperty("rawFsdState").GetInt32()!=2||sample.GetProperty("state").GetInt32() is <0 or >3)return null;
var now=DateTimeOffset.UtcNow;var at=DateTimeOffset.FromUnixTimeMilliseconds(sample.GetProperty("observedAtUnixMilliseconds").GetInt64());
if(at>now||now-at>TimeSpan.FromMilliseconds(200)||flight.ObservedAt>now||now-flight.ObservedAt>TimeSpan.FromMilliseconds(200))return null;
if(ScoThermalJson.Read(sample,flight.ShipEntity,flight.ReferenceWorld,at) is not {} model)return null;
var age=(now-at).TotalSeconds;
var forecast=ScoThermalPredictor.Predict(model with{HeatPercent=Math.Max(model.HeatPercent,flight.HeatPercent!.Value),
SourceAgeSeconds=model.SourceAgeSeconds+age,HeatAgeSeconds=model.HeatAgeSeconds+age,LeaseRemainingSeconds=.25});
if(!forecast.Known||forecast.ScoOffNetRisePercentPerSecond>=0)return null;
var oldest=at.AddSeconds(-Math.Max(model.SourceAgeSeconds,model.HeatAgeSeconds));
if(flight.ObservedAt<oldest)oldest=flight.ObservedAt;
return oldest.AddMilliseconds(200);
}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException or InvalidOperationException
or InvalidDataException or KeyNotFoundException or FormatException or OverflowException or ArgumentOutOfRangeException)
{return null;}
}
public async Task<NativeCruiseControlCapabilities> PrepareAsync(string packageDirectory,CancellationToken token)
{
try{return await InspectAsync(token).ConfigureAwait(false);}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){}
var hello=await new NavigationPipeClient(processId).CallAsync(new{op="describe"},token).ConfigureAwait(false);
if(hello.GetProperty("processId").GetInt32()!=processId||hello.GetProperty("gameSha256").GetString()!=gameSha256)
throw new InvalidDataException("SCO扩展与当前游戏实例不匹配。");
await RetireIdleLegacyAsync(token).ConfigureAwait(false);
await RuntimeInteractionModule.LoadAsync(processId,gameSha256,hello.GetProperty("owningThread").GetInt32(),packageDirectory,token).ConfigureAwait(false);
return await InspectAsync(token).ConfigureAwait(false);
}
private async Task RetireIdleLegacyAsync(CancellationToken token)
{
var legacy=new NavigationPipeClient(processId,CruiseControlProtocol.LegacyService);JsonElement status;
try{status=await legacy.CallAsync(new{op="read"},token).ConfigureAwait(false);}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){return;}
if(!Flag(status,"enabled"))return;
var now=DateTimeOffset.UtcNow;
if(Flag(status,"owned")||status.GetProperty("leaseExpiresAtUnixMilliseconds").GetInt64()>now.ToUnixTimeMilliseconds())
throw new FlightTelemetryUnavailableException("等待上一段 SCO 归还控制后连接新版扩展。");
status=await legacy.CallAsync(new{op="watch"},token).ConfigureAwait(false);
var until=DateTimeOffset.UtcNow.AddSeconds(2);
while(!CruiseControllerReplacement.CanRetire(status,processId,gameSha256,DateTimeOffset.UtcNow))
{
if(DateTimeOffset.UtcNow>=until)throw new FlightTelemetryUnavailableException("等待原版 SCO 停止,当前飞行保留。");
await Task.Delay(25,token);status=await legacy.CallAsync(new{op="read"},token).ConfigureAwait(false);
}
var retired=await legacy.CallAsync(new{op="deactivate"},token).ConfigureAwait(false);
if(Flag(retired,"enabled")||Flag(retired,"owned"))throw new InvalidDataException("上一版 SCO 扩展尚未确认释放。");
}
public async Task UpdateAsync(string controllerOwner,ulong selectionGeneration,SystemFlightFrame flight,SystemFlightDecision decision,
SystemNavigationTarget target,double reserve,DateTimeOffset deadline,CancellationToken token)
{
applicationFrame=flight;applicationDecision=decision;applicationDeadline=deadline;applicationStage="flight-update";applicationDetail="";
if(owner.Length!=0&&owner!=controllerOwner)throw new InvalidOperationException("SCO channel is already bound to another controller.");
owner=controllerOwner;
var mayBoost=flight.Mode==LocalFlightMode.Supercruise&&(decision.Actions&~SystemNavigationAction.CancelOwnedAssists)==SystemNavigationAction.None
&&decision.Phase is SystemNavigationPhase.Supercruise or SystemNavigationPhase.Aligning;
if(!mayBoost){await ReleaseAsync(token,"ineligible-flight-phase");return;}
if(DateTimeOffset.UtcNow<nativeStartRefusedUntil&&!leaseRequested)return;
if(flight.GameSha256!=gameSha256||flight.ShipEntity==0||flight.ReferenceWorld==0||selectionGeneration==0)
throw new InvalidDataException("SCO requires the current original flight target and controller.");
if(entity!=flight.ShipEntity||world!=flight.ReferenceWorld||boundLoadoutFingerprint!=flight.LoadoutFingerprint)
{
await ReleaseAsync(token);var bound=await pipe.CallAsync(new{op="watch",loadoutFingerprint=flight.LoadoutFingerprint},token);Capture(bound);
entity=flight.ShipEntity;world=flight.ReferenceWorld;boundLoadoutFingerprint=flight.LoadoutFingerprint;
boundInterface=bound.TryGetProperty("watchInterface",out var component)&&component.ValueKind==JsonValueKind.String
&&ulong.TryParse(component.GetString(),NumberStyles.HexNumber,CultureInfo.InvariantCulture,out var address)?address:0;
history.Reset();originalStartWitness=false;
}
var status=await pipe.CallAsync(new{op="read"},token);Capture(status);
if(leaseRequested&&!Flag(status,"owned"))
{
var safelyStopped=originalStartWitness&&ScoStoppedBurst.CanContinue(status,owner,originalStartRequestId,selectionGeneration,flight,DateTimeOffset.UtcNow);
RememberApproachStop(status,flight,selectionGeneration);
// The native guard may stop our burst between updates. Observe
// its real Off state before requesting another original command.
await ReleaseAsync(token);
if(safelyStopped)
{
policy.ObserveNativeStartRefusal(DateTimeOffset.UtcNow);nativeStartRefusedUntil=DateTimeOffset.UtcNow.AddSeconds(1);
LastDecision=new(false,CruiseOverchargeReason.Unavailable);return;
}
throw new FlightTelemetryUnavailableException("Original SCO burst ended; refresh flight telemetry before continuing.");
}
var observed=history.Read(status,flight,DateTimeOffset.UtcNow,allowRepeated:leaseRequested&&Flag(status,"owned"));LastObservation=observed;
if(!Flag(status,"owned")&&status.TryGetProperty("sample",out var sample)&&sample.ValueKind==JsonValueKind.Object&&!Flag(sample,"active"))originalStartWitness=false;
var selected=policy.Decide(flight,observed,reserve,DateTimeOffset.UtcNow,deadline);LastDecision=selected;
applicationStage="policy";Capture(status);
if(!selected.UseOvercharge){await ReleaseAsync(token,"policy:"+selected.Reason);return;}
var requestId=Guid.NewGuid().ToString("N");
// Mark before IPC: a lost reply may still have queued an original call.
leaseRequested=true;
JsonElement result;
try
{
result=await pipe.CallAsync(new{op="cruise.set",owner,requestId,selectionGeneration,sequence=flight.Sequence,
expiresAtUnixMilliseconds=deadline.ToUnixTimeMilliseconds(),requiredFuelReserveTonnes=reserve,
target=new{key=target.NativeKey,bodyAddress=target.BodyAddress.ToString(CultureInfo.InvariantCulture),kind=target.NativeKind,marketId=target.MarketId??uint.MaxValue,name=target.Name}},token);
}
catch(InvalidOperationException error)when(error.Message=="SCO was already active outside this controller."&&originalStartWitness)
{
var stopped=await pipe.CallAsync(new{op="read"},token);Capture(stopped);
if(Flag(stopped,"owned")||!stopped.TryGetProperty("lastOriginalStop",out var stop)||stop.ValueKind!=JsonValueKind.Object
||stop.GetProperty("owner").GetString()!=owner||stop.GetProperty("startRequestId").GetString()!=originalStartRequestId
||stop.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(stop,"entityId")!=entity||Identity(stop,"referenceWorldAddress")!=world
||!Flag(stop,"nativeMethodCalled")||Flag(stop,"argument")||stop.GetProperty("nativeMethodRva").GetString()!="2eaacd0")throw;
await ReleaseAsync(token);
throw new FlightTelemetryUnavailableException("Original SCO stop overlapped its renewal; refresh telemetry before continuing.",error);
}
var command=result.GetProperty("generation").GetUInt64();
while(true)
{
Capture(result);
if(result.TryGetProperty("receipt",out var receipt)&&receipt.ValueKind==JsonValueKind.Object&&receipt.GetProperty("requestId").GetString()==requestId)
{
if(Flag(receipt,"failed"))
{
// A rejected optional start is not loss of ordinary flight
// telemetry. Only a matching, never-called, healthy/off
// receipt can leave the primary control lease running.
if(!originalStartWitness&&ScoStartRefusal.CanContinueOrdinaryFlight(result,receipt,owner,requestId,command,selectionGeneration,flight,DateTimeOffset.UtcNow))
{
leaseRequested=false;pendingStopId=null;nativeStartRefusedUntil=DateTimeOffset.UtcNow.AddSeconds(1);
policy.ObserveNativeStartRefusal(DateTimeOffset.UtcNow);
LastDecision=new(false,CruiseOverchargeReason.Unavailable);return;
}
throw new FlightTelemetryUnavailableException("Original SCO interlock refused: "+(receipt.TryGetProperty("reason",out var why)?why.GetString():"unknown"));
}
if(Flag(receipt,"applied"))
{
if(receipt.GetProperty("owner").GetString()!=owner||receipt.GetProperty("generation").GetUInt64()!=command
||receipt.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(receipt,"entityId")!=entity||Identity(receipt,"referenceWorldAddress")!=world)
throw new InvalidDataException("Original SCO receipt identity differs.");
var called=Flag(receipt,"nativeMethodCalled");
if(called)
{
if(receipt.GetProperty("nativeMethodRva").GetString()!="2eaacd0"||!Flag(receipt,"argument")||receipt.GetProperty("actualStateAfterCall").GetInt32() is not(1 or 2))
throw new InvalidDataException("Original SCO enable was not confirmed.");
originalStartWitness=true;
originalStartRequestId=requestId;
originalStartSelection=selectionGeneration;
originalStartAt=receipt.TryGetProperty("observedAtUnixMilliseconds",out var started)&&started.TryGetInt64(out var startAt)?startAt:0;
}
else if(!originalStartWitness)throw new InvalidDataException("SCO renewal has no original enable witness.");
OriginalReceipt?.Invoke(receipt.Clone());return;
}
}
if(DateTimeOffset.UtcNow>=deadline)throw new FlightTelemetryUnavailableException("Original SCO command expired without a matching receipt.");
await Task.Delay(10,token);result=await pipe.CallAsync(new{op="read"},token);
}
}
private void RememberApproachStop(JsonElement status,SystemFlightFrame flight,ulong selectionGeneration)
{
if(!originalStartWitness||originalStartRequestId is null||flight.SelectedTarget is null
||!status.TryGetProperty("lastOriginalStop",out var stop)||stop.ValueKind!=JsonValueKind.Object
||stop.GetProperty("owner").GetString()!=owner||stop.GetProperty("startRequestId").GetString()!=originalStartRequestId
||stop.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(stop,"entityId")!=entity||Identity(stop,"referenceWorldAddress")!=world
||!Flag(stop,"nativeMethodCalled")||Flag(stop,"argument")||stop.GetProperty("nativeMethodRva").GetString()!="2eaacd0"
||stop.GetProperty("reason").GetString()!="approach-or-alignment"||!stop.TryGetProperty("gate",out var gate)||gate.ValueKind!=JsonValueKind.Object
||!Flag(gate,"identityVerified")||!Flag(gate,"targetVerified")||!Flag(gate,"geometryVerified"))return;
static double Number(JsonElement row,string key)=>row.TryGetProperty(key,out var v)&&v.ValueKind==JsonValueKind.Number?v.GetDouble():double.NaN;
var distance=Number(gate,"distanceMetres");var speed=Number(gate,"speedMetresPerSecond");
var approach=ScoExitApproachDecision.Read(gate);
var stoppedAt=stop.GetProperty("observedAtUnixMilliseconds").GetUInt64();
if(double.IsFinite(distance)&&distance>0&&double.IsFinite(speed)&&speed>=0
&&(approach?.RequiresStop(distance,speed,true,Flag(gate,"cruiseParametersVerified"),Number(gate,"cruiseApproachSeconds"),
entity,world,flight.SelectedTarget.NativeKey,stoppedAt)
??ScoApproachEnvelope.RequiresStop(distance,speed,true,Flag(gate,"cruiseParametersVerified"),Number(gate,"cruiseApproachSeconds"))))
policy.ObserveNativeApproachStop(flight);
}
public async Task ReleaseAsync(CancellationToken token=default,string cause="driver-release")
{
if(!leaseRequested)return;
applicationStage="release";applicationDetail=cause;
pendingStopId??=Guid.NewGuid().ToString("N");
var result=await pipe.CallAsync(new{op="cruise.release",owner,requestId=pendingStopId},token);
var until=DateTimeOffset.UtcNow.AddSeconds(2);
while(true)
{
var status=await pipe.CallAsync(new{op="read"},token);Capture(status);
if(!Flag(status,"owned")&&Flag(status,"sampleReady")&&status.TryGetProperty("sample",out var sample)&&sample.ValueKind==JsonValueKind.Object
&&Identity(sample,"entityId")==entity&&Identity(sample,"referenceWorldAddress")==world&&sample.GetProperty("state").GetInt32() is 0 or 3)
{leaseRequested=false;originalStartWitness=false;pendingStopId=null;return;}
// A natural SOI transition changes the observed world before the
// old watch binding can be replaced. Its old world will never be
// sampled again. Only our actual stop and the same live component
// reporting Off in its new world can finish this old ownership.
if(originalStartWitness&&ScoReferenceWorldRelease.CanConfirm(status,owner,originalStartRequestId,
originalStartSelection,originalStartAt,entity,world,boundInterface,DateTimeOffset.UtcNow))
{leaseRequested=false;originalStartWitness=false;pendingStopId=null;return;}
if(!Flag(status,"owned")&&result.TryGetProperty("released",out var released)&&released.ValueKind==JsonValueKind.True&&!originalStartWitness)
{leaseRequested=false;pendingStopId=null;return;}
if(DateTimeOffset.UtcNow>=until)throw new FlightTelemetryUnavailableException("Waiting for the owned SCO to report its real stopped state.");
await Task.Delay(15,token);
}
}
}
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Plugins;
namespace XFEEDToolBox.Core.Navigation;
public sealed record NativeCruiseControlCapabilities(bool Available,bool Qualified,string Service,int OwningThread);
/// <summary>Program-owned SCO policy and bounded original-command receipts. Never steers or selects a target.</summary>
public sealed class NativeCruiseControlChannel(int processId,string gameSha256)
{
public const string Service=CruiseControlProtocol.Service;
private readonly NavigationPipeClient pipe=new(processId,Service);
private readonly CruiseOverchargeSampleHistory history=new(gameSha256);
private readonly CruiseOverchargePolicy policy=new();
private ulong entity,world;
private ulong boundInterface,originalStartSelection;
private long originalStartAt;
private bool leaseRequested,originalStartWitness;
private string owner="";
private string boundLoadoutFingerprint="";
private string? pendingStopId,lastStopIdentity,originalStartRequestId;
private DateTimeOffset nativeStartRefusedUntil;
private SystemFlightFrame? applicationFrame;
private SystemFlightDecision? applicationDecision;
private DateTimeOffset applicationDeadline;
private string applicationStage="",applicationDetail="";
public JsonElement? LastStatus{get;private set;}
public CruiseOverchargeObservation? LastObservation{get;private set;}
public CruiseOverchargeDecision? LastDecision{get;private set;}
public event Action<JsonElement>? Observed;
public event Action<JsonElement>? OriginalReceipt;
public event Action<JsonElement>? OriginalStopReceipt;
/// <summary>Only arms a bounded strategy trial; does not enable SCO, steer, select or start navigation.</summary>
public Task<JsonElement> ConfigureExitEstimateTrialAsync(ulong shipEntity,ulong referenceWorld,DateTimeOffset expires,int maximumBursts,CancellationToken token=default)
=>pipe.CallAsync(new{op="sco.exit.configure",mode="trial",entityId=shipEntity,referenceWorldAddress=referenceWorld,
expiresAtUnixMilliseconds=expires.ToUnixTimeMilliseconds(),maximumBursts},token);
public Task<JsonElement> UseShadowExitEstimateAsync(CancellationToken token=default)=>pipe.CallAsync(new{op="sco.exit.configure",mode="shadow"},token);
public Task<JsonElement> PromoteExitEstimateProductionAsync(ulong shipEntity,ulong referenceWorld,DateTimeOffset expires,CancellationToken token=default)
=>pipe.CallAsync(new{op="sco.exit.configure",mode="production",entityId=shipEntity,referenceWorldAddress=referenceWorld,
expiresAtUnixMilliseconds=expires.ToUnixTimeMilliseconds()},token);
private static bool Flag(JsonElement value,string key)=>value.TryGetProperty(key,out var found)&&found.ValueKind==JsonValueKind.True;
private static ulong Identity(JsonElement value,string key)=>ulong.Parse(value.GetProperty(key).GetString()!,CultureInfo.InvariantCulture);
private static bool Key(JsonElement value,string key)=>value.TryGetProperty(key,out var item)&&item.ValueKind==JsonValueKind.String
&&item.GetString() is {Length:32} text&&text.All(c=>c is >= '0' and <= '9' or >= 'a' and <= 'f');
private void CheckIdentity(JsonElement result)
{
if(result.GetProperty("schema").GetInt32()!=1||result.GetProperty("processId").GetInt32()!=processId
||result.GetProperty("gameSha256").GetString()!=gameSha256||result.GetProperty("service").GetString()!=Service
||!Flag(result,"overchargeControl")||!Flag(result,"enabled")||!Flag(result,"originalFuelRangeObservations")
||!Flag(result,"nativeStopReceipts")||!Flag(result,"componentThermalModel")||!Flag(result,"expiringOriginalControl")||result.GetProperty("controlTelemetrySchema").GetInt32()!=1
||result.GetProperty("owningThread").GetInt32()<=0)throw new InvalidDataException("Original SCO controller does not match this client.");
if(Flag(result,"overchargeControlQualified"))
{
if(!result.TryGetProperty("qualifiedCycle",out var cycle)||cycle.ValueKind!=JsonValueKind.Object
||!Key(cycle,"owner")||!Key(cycle,"startRequestId")||!Key(cycle,"releaseRequestId")
||cycle.GetProperty("selectionGeneration").GetUInt64()==0||Identity(cycle,"entityId")==0||Identity(cycle,"referenceWorldAddress")==0
||cycle.GetProperty("activeSamples").GetInt32()<5)throw new InvalidDataException("Original SCO qualification has no matching live cycle.");
var enabledAt=cycle.GetProperty("enableAtUnixMilliseconds").GetUInt64();var activeAt=cycle.GetProperty("lastActiveAtUnixMilliseconds").GetUInt64();
var stopAt=cycle.GetProperty("originalStopAtUnixMilliseconds").GetUInt64();var offAt=cycle.GetProperty("offAtUnixMilliseconds").GetUInt64();
if(enabledAt==0||activeAt<enabledAt||stopAt<activeAt||offAt<=stopAt||offAt>(ulong)DateTimeOffset.UtcNow.AddMilliseconds(100).ToUnixTimeMilliseconds())
throw new InvalidDataException("Original SCO qualification timestamps do not establish enable, Active, stop and subsequent Off.");
}
}
private void Capture(JsonElement result)
{
CheckIdentity(result);LastStatus=result.Clone();
if(result.TryGetProperty("lastOriginalStop",out var stop)&&stop.ValueKind==JsonValueKind.Object&&stop.GetProperty("owner").GetString()==owner)
{
var id=stop.GetRawText();if(id!=lastStopIdentity){lastStopIdentity=id;OriginalStopReceipt?.Invoke(stop.Clone());}
}
// A real Off sample in this response follows its original stop receipt.
var traced=JsonNode.Parse(result.GetRawText())!.AsObject();
traced["applicationControl"]=JsonSerializer.SerializeToNode(new{stage=applicationStage,detail=applicationDetail,
sequence=applicationFrame?.Sequence,flightObservedAt=applicationFrame?.ObservedAt,deadline=applicationDeadline,
phase=applicationDecision?.Phase.ToString(),actions=applicationDecision?.Actions.ToString(),
useOvercharge=LastDecision?.UseOvercharge,policyReason=LastDecision?.Reason.ToString()});
Observed?.Invoke(JsonSerializer.SerializeToElement(traced));
}
public async Task<NativeCruiseControlCapabilities> InspectAsync(CancellationToken token)
{
var result=await pipe.CallAsync(new{op="describe"},token);CheckIdentity(result);
return new(true,Flag(result,"overchargeControlQualified"),Service,result.GetProperty("owningThread").GetInt32());
}
/// <summary>Read-only proof for the local navigator; does not consume or advance command sample history.</summary>
public async Task<DateTimeOffset?> ReadThermalControlExpiryAsync(SystemFlightFrame flight,CancellationToken token)
{
if(flight.Mode!=LocalFlightMode.Supercruise||flight.Assists.Supercruise
||flight.SupercruiseAssist is NativeAssistState.Requested or NativeAssistState.Aligning or NativeAssistState.Engaged
||!flight.InGame||flight.PlayerOverride||flight.ExternalThreat||flight.ShipEntity==0||flight.ReferenceWorld==0
||flight.GameSha256!=gameSha256||flight.HeatPercent is not >=0 or >=100)return null;
try
{
var status=await pipe.CallAsync(new{op="read"},token);CheckIdentity(status);
if(!Flag(status,"sampleReady")||!status.TryGetProperty("sample",out var sample)||sample.ValueKind!=JsonValueKind.Object
||!Flag(sample,"verified")||!Flag(sample,"inSupercruise")||!Flag(sample,"stableFsd")
||Identity(sample,"entityId")!=flight.ShipEntity||Identity(sample,"referenceWorldAddress")!=flight.ReferenceWorld
||sample.GetProperty("rawFsdState").GetInt32()!=2||sample.GetProperty("state").GetInt32() is <0 or >3)return null;
var now=DateTimeOffset.UtcNow;var at=DateTimeOffset.FromUnixTimeMilliseconds(sample.GetProperty("observedAtUnixMilliseconds").GetInt64());
if(at>now||now-at>TimeSpan.FromMilliseconds(200)||flight.ObservedAt>now||now-flight.ObservedAt>TimeSpan.FromMilliseconds(200))return null;
if(ScoThermalJson.Read(sample,flight.ShipEntity,flight.ReferenceWorld,at) is not {} model)return null;
var age=(now-at).TotalSeconds;
var forecast=ScoThermalPredictor.Predict(model with{HeatPercent=Math.Max(model.HeatPercent,flight.HeatPercent!.Value),
SourceAgeSeconds=model.SourceAgeSeconds+age,HeatAgeSeconds=model.HeatAgeSeconds+age,LeaseRemainingSeconds=.25});
if(!forecast.Known||forecast.ScoOffNetRisePercentPerSecond>=0)return null;
var oldest=at.AddSeconds(-Math.Max(model.SourceAgeSeconds,model.HeatAgeSeconds));
if(flight.ObservedAt<oldest)oldest=flight.ObservedAt;
return oldest.AddMilliseconds(200);
}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException or InvalidOperationException
or InvalidDataException or KeyNotFoundException or FormatException or OverflowException or ArgumentOutOfRangeException)
{return null;}
}
public async Task<NativeCruiseControlCapabilities> PrepareAsync(string packageDirectory,CancellationToken token)
{
try{return await InspectAsync(token).ConfigureAwait(false);}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){}
var hello=await new NavigationPipeClient(processId).CallAsync(new{op="describe"},token).ConfigureAwait(false);
if(hello.GetProperty("processId").GetInt32()!=processId||hello.GetProperty("gameSha256").GetString()!=gameSha256)
throw new InvalidDataException("SCO扩展与当前游戏实例不匹配。");
await RetireIdleLegacyAsync(token).ConfigureAwait(false);
await RuntimeInteractionModule.LoadAsync(processId,gameSha256,hello.GetProperty("owningThread").GetInt32(),packageDirectory,token).ConfigureAwait(false);
return await InspectAsync(token).ConfigureAwait(false);
}
private async Task RetireIdleLegacyAsync(CancellationToken token)
{
var legacy=new NavigationPipeClient(processId,CruiseControlProtocol.LegacyService);JsonElement status;
try{status=await legacy.CallAsync(new{op="read"},token).ConfigureAwait(false);}
catch(Exception error)when(!token.IsCancellationRequested&&error is IOException or OperationCanceledException){return;}
if(!Flag(status,"enabled"))return;
var now=DateTimeOffset.UtcNow;
if(Flag(status,"owned")||status.GetProperty("leaseExpiresAtUnixMilliseconds").GetInt64()>now.ToUnixTimeMilliseconds())
throw new FlightTelemetryUnavailableException("等待上一段 SCO 归还控制后连接新版扩展。");
status=await legacy.CallAsync(new{op="watch"},token).ConfigureAwait(false);
var until=DateTimeOffset.UtcNow.AddSeconds(2);
while(!CruiseControllerReplacement.CanRetire(status,processId,gameSha256,DateTimeOffset.UtcNow))
{
if(DateTimeOffset.UtcNow>=until)throw new FlightTelemetryUnavailableException("等待原版 SCO 停止,当前飞行保留。");
await Task.Delay(25,token);status=await legacy.CallAsync(new{op="read"},token).ConfigureAwait(false);
}
var retired=await legacy.CallAsync(new{op="deactivate"},token).ConfigureAwait(false);
if(Flag(retired,"enabled")||Flag(retired,"owned"))throw new InvalidDataException("上一版 SCO 扩展尚未确认释放。");
}
public async Task UpdateAsync(string controllerOwner,ulong selectionGeneration,SystemFlightFrame flight,SystemFlightDecision decision,
SystemNavigationTarget target,double reserve,DateTimeOffset deadline,CancellationToken token)
{
applicationFrame=flight;applicationDecision=decision;applicationDeadline=deadline;applicationStage="flight-update";applicationDetail="";
if(owner.Length!=0&&owner!=controllerOwner)throw new InvalidOperationException("SCO channel is already bound to another controller.");
owner=controllerOwner;
var mayBoost=flight.Mode==LocalFlightMode.Supercruise&&(decision.Actions&~SystemNavigationAction.CancelOwnedAssists)==SystemNavigationAction.None
&&decision.Phase is SystemNavigationPhase.Supercruise or SystemNavigationPhase.Aligning;
if(!mayBoost){await ReleaseAsync(token,"ineligible-flight-phase");return;}
if(DateTimeOffset.UtcNow<nativeStartRefusedUntil&&!leaseRequested)return;
if(flight.GameSha256!=gameSha256||flight.ShipEntity==0||flight.ReferenceWorld==0||selectionGeneration==0)
throw new InvalidDataException("SCO requires the current original flight target and controller.");
if(entity!=flight.ShipEntity||world!=flight.ReferenceWorld||boundLoadoutFingerprint!=flight.LoadoutFingerprint)
{
await ReleaseAsync(token);var bound=await pipe.CallAsync(new{op="watch",loadoutFingerprint=flight.LoadoutFingerprint},token);Capture(bound);
entity=flight.ShipEntity;world=flight.ReferenceWorld;boundLoadoutFingerprint=flight.LoadoutFingerprint;
boundInterface=bound.TryGetProperty("watchInterface",out var component)&&component.ValueKind==JsonValueKind.String
&&ulong.TryParse(component.GetString(),NumberStyles.HexNumber,CultureInfo.InvariantCulture,out var address)?address:0;
history.Reset();originalStartWitness=false;
}
var status=await pipe.CallAsync(new{op="read"},token);Capture(status);
if(leaseRequested&&!Flag(status,"owned"))
{
var safelyStopped=originalStartWitness&&ScoStoppedBurst.CanContinue(status,owner,originalStartRequestId,selectionGeneration,flight,DateTimeOffset.UtcNow);
RememberApproachStop(status,flight,selectionGeneration);
// The native guard may stop our burst between updates. Observe
// its real Off state before requesting another original command.
await ReleaseAsync(token);
if(safelyStopped)
{
policy.ObserveNativeStartRefusal(DateTimeOffset.UtcNow);nativeStartRefusedUntil=DateTimeOffset.UtcNow.AddSeconds(1);
LastDecision=new(false,CruiseOverchargeReason.Unavailable);return;
}
throw new FlightTelemetryUnavailableException("Original SCO burst ended; refresh flight telemetry before continuing.");
}
var observed=history.Read(status,flight,DateTimeOffset.UtcNow,allowRepeated:leaseRequested&&Flag(status,"owned"));LastObservation=observed;
if(!Flag(status,"owned")&&status.TryGetProperty("sample",out var sample)&&sample.ValueKind==JsonValueKind.Object&&!Flag(sample,"active"))originalStartWitness=false;
var selected=policy.Decide(flight,observed,reserve,DateTimeOffset.UtcNow,deadline);LastDecision=selected;
applicationStage="policy";Capture(status);
if(!selected.UseOvercharge){await ReleaseAsync(token,"policy:"+selected.Reason);return;}
var requestId=Guid.NewGuid().ToString("N");
// Mark before IPC: a lost reply may still have queued an original call.
leaseRequested=true;
JsonElement result;
try
{
result=await pipe.CallAsync(new{op="cruise.set",owner,requestId,selectionGeneration,sequence=flight.Sequence,
expiresAtUnixMilliseconds=deadline.ToUnixTimeMilliseconds(),requiredFuelReserveTonnes=reserve,
target=new{key=target.NativeKey,bodyAddress=target.BodyAddress.ToString(CultureInfo.InvariantCulture),kind=target.NativeKind,marketId=target.MarketId??uint.MaxValue,name=target.Name}},token);
}
catch(InvalidOperationException error)when(error.Message=="SCO was already active outside this controller."&&originalStartWitness)
{
var stopped=await pipe.CallAsync(new{op="read"},token);Capture(stopped);
if(Flag(stopped,"owned")||!stopped.TryGetProperty("lastOriginalStop",out var stop)||stop.ValueKind!=JsonValueKind.Object
||stop.GetProperty("owner").GetString()!=owner||stop.GetProperty("startRequestId").GetString()!=originalStartRequestId
||stop.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(stop,"entityId")!=entity||Identity(stop,"referenceWorldAddress")!=world
||!Flag(stop,"nativeMethodCalled")||Flag(stop,"argument")||stop.GetProperty("nativeMethodRva").GetString()!="2eaacd0")throw;
await ReleaseAsync(token);
throw new FlightTelemetryUnavailableException("Original SCO stop overlapped its renewal; refresh telemetry before continuing.",error);
}
var command=result.GetProperty("generation").GetUInt64();
while(true)
{
Capture(result);
if(result.TryGetProperty("receipt",out var receipt)&&receipt.ValueKind==JsonValueKind.Object&&receipt.GetProperty("requestId").GetString()==requestId)
{
if(Flag(receipt,"failed"))
{
// A rejected optional start is not loss of ordinary flight
// telemetry. Only a matching, never-called, healthy/off
// receipt can leave the primary control lease running.
if(!originalStartWitness&&ScoStartRefusal.CanContinueOrdinaryFlight(result,receipt,owner,requestId,command,selectionGeneration,flight,DateTimeOffset.UtcNow))
{
leaseRequested=false;pendingStopId=null;nativeStartRefusedUntil=DateTimeOffset.UtcNow.AddSeconds(1);
policy.ObserveNativeStartRefusal(DateTimeOffset.UtcNow);
LastDecision=new(false,CruiseOverchargeReason.Unavailable);return;
}
throw new FlightTelemetryUnavailableException("Original SCO interlock refused: "+(receipt.TryGetProperty("reason",out var why)?why.GetString():"unknown"));
}
if(Flag(receipt,"applied"))
{
if(receipt.GetProperty("owner").GetString()!=owner||receipt.GetProperty("generation").GetUInt64()!=command
||receipt.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(receipt,"entityId")!=entity||Identity(receipt,"referenceWorldAddress")!=world)
throw new InvalidDataException("Original SCO receipt identity differs.");
var called=Flag(receipt,"nativeMethodCalled");
if(called)
{
if(receipt.GetProperty("nativeMethodRva").GetString()!="2eaacd0"||!Flag(receipt,"argument")||receipt.GetProperty("actualStateAfterCall").GetInt32() is not(1 or 2))
throw new InvalidDataException("Original SCO enable was not confirmed.");
originalStartWitness=true;
originalStartRequestId=requestId;
originalStartSelection=selectionGeneration;
originalStartAt=receipt.TryGetProperty("observedAtUnixMilliseconds",out var started)&&started.TryGetInt64(out var startAt)?startAt:0;
}
else if(!originalStartWitness)throw new InvalidDataException("SCO renewal has no original enable witness.");
OriginalReceipt?.Invoke(receipt.Clone());return;
}
}
if(DateTimeOffset.UtcNow>=deadline)throw new FlightTelemetryUnavailableException("Original SCO command expired without a matching receipt.");
await Task.Delay(10,token);result=await pipe.CallAsync(new{op="read"},token);
}
}
private void RememberApproachStop(JsonElement status,SystemFlightFrame flight,ulong selectionGeneration)
{
if(!originalStartWitness||originalStartRequestId is null||flight.SelectedTarget is null
||!status.TryGetProperty("lastOriginalStop",out var stop)||stop.ValueKind!=JsonValueKind.Object
||stop.GetProperty("owner").GetString()!=owner||stop.GetProperty("startRequestId").GetString()!=originalStartRequestId
||stop.GetProperty("selectionGeneration").GetUInt64()!=selectionGeneration||Identity(stop,"entityId")!=entity||Identity(stop,"referenceWorldAddress")!=world
||!Flag(stop,"nativeMethodCalled")||Flag(stop,"argument")||stop.GetProperty("nativeMethodRva").GetString()!="2eaacd0"
||stop.GetProperty("reason").GetString()!="approach-or-alignment"||!stop.TryGetProperty("gate",out var gate)||gate.ValueKind!=JsonValueKind.Object
||!Flag(gate,"identityVerified")||!Flag(gate,"targetVerified")||!Flag(gate,"geometryVerified"))return;
static double Number(JsonElement row,string key)=>row.TryGetProperty(key,out var v)&&v.ValueKind==JsonValueKind.Number?v.GetDouble():double.NaN;
var distance=Number(gate,"distanceMetres");var speed=Number(gate,"speedMetresPerSecond");
var approach=ScoExitApproachDecision.Read(gate);
var stoppedAt=stop.GetProperty("observedAtUnixMilliseconds").GetUInt64();
if(double.IsFinite(distance)&&distance>0&&double.IsFinite(speed)&&speed>=0
&&(approach?.RequiresStop(distance,speed,true,Flag(gate,"cruiseParametersVerified"),Number(gate,"cruiseApproachSeconds"),
entity,world,flight.SelectedTarget.NativeKey,stoppedAt)
??ScoApproachEnvelope.RequiresStop(distance,speed,true,Flag(gate,"cruiseParametersVerified"),Number(gate,"cruiseApproachSeconds"))))
policy.ObserveNativeApproachStop(flight);
}
public async Task ReleaseAsync(CancellationToken token=default,string cause="driver-release")
{
if(!leaseRequested)return;
applicationStage="release";applicationDetail=cause;
pendingStopId??=Guid.NewGuid().ToString("N");
var result=await pipe.CallAsync(new{op="cruise.release",owner,requestId=pendingStopId},token);
var until=DateTimeOffset.UtcNow.AddSeconds(2);
while(true)
{
var status=await pipe.CallAsync(new{op="read"},token);Capture(status);
if(!Flag(status,"owned")&&Flag(status,"sampleReady")&&status.TryGetProperty("sample",out var sample)&&sample.ValueKind==JsonValueKind.Object
&&Identity(sample,"entityId")==entity&&Identity(sample,"referenceWorldAddress")==world&&sample.GetProperty("state").GetInt32() is 0 or 3)
{leaseRequested=false;originalStartWitness=false;pendingStopId=null;return;}
// A natural SOI transition changes the observed world before the
// old watch binding can be replaced. Its old world will never be
// sampled again. Only our actual stop and the same live component
// reporting Off in its new world can finish this old ownership.
if(originalStartWitness&&ScoReferenceWorldRelease.CanConfirm(status,owner,originalStartRequestId,
originalStartSelection,originalStartAt,entity,world,boundInterface,DateTimeOffset.UtcNow))
{leaseRequested=false;originalStartWitness=false;pendingStopId=null;return;}
if(!Flag(status,"owned")&&result.TryGetProperty("released",out var released)&&released.ValueKind==JsonValueKind.True&&!originalStartWitness)
{leaseRequested=false;pendingStopId=null;return;}
if(DateTimeOffset.UtcNow>=until)throw new FlightTelemetryUnavailableException("Waiting for the owned SCO to report its real stopped state.");
await Task.Delay(15,token);
}
}
}