using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
internal static class DockingFixtureWait
{
// A continuously running, explicitly launched test workflow. No scheduler,
// new game process, process termination, OS input, or arbitrary mode change.
internal static async Task RunAsync(int pid,long started,int observerPid,long observerStarted,string root,string report)
{
root=Path.GetFullPath(root);report=Path.GetFullPath(report);
if(Path.GetDirectoryName(report)!=root||File.Exists(report))throw new IOException("Use a fresh report in the existing trade work directory.");
Directory.CreateDirectory(root);var stopFile=report+".stop";var fixtureReport=report+".fixture.json";
using var exclusive=new FileStream(Path.Combine(root,"docking-fixture-wait.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
using var stop=new CancellationTokenSource();var token=stop.Token;
await using var log=new StreamWriter(new FileStream(report+".jsonl",FileMode.CreateNew,FileAccess.Write,FileShare.ReadWrite|FileShare.Delete));
var settings=ToolboxSettings.Load(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"XFEstudio","XFEEDToolBox","settings.json"));
await using var journal=new JournalMonitor(settings.JournalDirectory);await journal.StartAsync();var known=journal.State;
if(known.CommanderId.Length==0||known.CurrentShipId!=5||known.CargoCount!=0||known.CargoCapacity!=192||known.GameMode!="Solo")throw new InvalidDataException("Known empty Solo Corsair identity required for the dedicated test.");
var pipe=new NavigationPipeClient(pid);var began=DateTimeOffset.UtcNow;var phase="waiting-original-session";string? reason=null,lastLog=null,lastState=null;
JsonElement? native=null;var attemptedEntry=false;var testStarted=false;long reads=0;bool? passed=null;DateTimeOffset lastWritten=default;
void Verify()
{
using var game=Process.GetProcessById(pid);using var observer=Process.GetProcessById(observerPid);
if(game.HasExited||game.StartTime.ToUniversalTime().Ticks!=started||game.ProcessName!="EliteDangerous64"
||!string.Equals(game.MainModule?.FileName,settings.PluginGameExecutable,StringComparison.OrdinalIgnoreCase)
||observer.HasExited||observer.StartTime.ToUniversalTime().Ticks!=observerStarted||observer.ProcessName!="NativeAcceptance")
throw new InvalidOperationException("Bound game or independent observer changed; test workflow stopped without retargeting.");
}
static JsonDocument SharedJson(string path)
{using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);return JsonDocument.Parse(stream);}
bool IdentityReady()
{
var value=journal.State;
return value.InGame&&value.GameMode=="Solo"&&value.CommanderId==known.CommanderId&&value.CurrentShipId==5&&value.CargoCount==0&&value.CargoCapacity==192
&&value.LoadGameAt is {} load&&load.UtcTicks>=started-TimeSpan.FromSeconds(5).Ticks
&&!NavigationSafety.UnhealthyHull(value)&&!NavigationSafety.HasExternalThreat(value);
}
async Task State(bool force=false)
{
if(!force&&DateTimeOffset.UtcNow-lastWritten<TimeSpan.FromSeconds(10))return;
var value=new JsonObject{["phase"]=phase,["at"]=DateTimeOffset.UtcNow,["began"]=began,["processId"]=pid,["gameStartedUtcTicks"]=started,
["observerPid"]=observerPid,["observerStartedUtcTicks"]=observerStarted,["stopFile"]=stopFile,["fixtureReport"]=fixtureReport,["sessionReads"]=reads,
["testStarted"]=testStarted,["identityReady"]=IdentityReady(),["entryAttempted"]=attemptedEntry,["reason"]=reason,["clientCreated"]=false,["clientTerminated"]=false,
["oldEntryFailurePreserved"]=true,["osInputSent"]=false,["tradeOrdersSent"]=0};
if(native is {} frame)value["session"]=JsonNode.Parse(frame.GetRawText());
if(passed is {} result)value["passed"]=result;
var text=value.ToJsonString(ToolboxSettings.JsonOptions);await File.WriteAllTextAsync(report+".tmp",text,CancellationToken.None);File.Move(report+".tmp",report,true);lastWritten=DateTimeOffset.UtcNow;
var key=phase+"|"+lastState+"|"+reason;
if(key!=lastLog){lastLog=key;await log.WriteLineAsync(text);await log.FlushAsync();Console.WriteLine(phase+" · "+(lastState??"")+" · "+reason);}
}
async Task<bool> ProtectionReady()
{
try
{
Verify();using var registry=SharedJson(Path.Combine(root,"active-ship-protection.json"));var r=registry.RootElement;
if(r.GetProperty("gamePid").GetInt32()!=pid||r.GetProperty("gameStartedUtcTicks").GetInt64()!=started||r.GetProperty("observerPid").GetInt32()!=observerPid||r.GetProperty("observerStartedUtcTicks").GetInt64()!=observerStarted)throw new InvalidDataException("Independent protection registry changed.");
var witness=Path.GetFullPath(r.GetProperty("report").GetString()!)+".ready.json";
if(Path.GetDirectoryName(witness)!=root)throw new InvalidDataException("Independent witness moved outside the work directory.");
using var document=SharedJson(witness);var w=document.RootElement;var now=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();var at=w.GetProperty("healthAtUnixMilliseconds").GetInt64();
if(!w.GetProperty("ready").GetBoolean()||w.GetProperty("gamePid").GetInt32()!=pid||w.GetProperty("gameStartedUtcTicks").GetInt64()!=started
||w.GetProperty("observerPid").GetInt32()!=observerPid||w.GetProperty("observerStartedUtcTicks").GetInt64()!=observerStarted||w.GetProperty("shipId").GetInt64()!=5||at>now+50||now-at>500)return false;
var health=await pipe.CallAsync(new{op="health.read"},token);var h=health.GetProperty("shipHealth");
return health.GetProperty("localPlayerShipVerified").GetBoolean()&&h.GetProperty("verified").GetBoolean()&&h.GetProperty("hullFraction").GetDouble()>=.95
&&DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()-h.GetProperty("observedAtUnixMilliseconds").GetInt64()<=500;
}
catch(Exception error)when(error is IOException or JsonException or KeyNotFoundException
||error is InvalidOperationException&&error.Message.StartsWith("Native main-ship health unavailable",StringComparison.Ordinal)){return false;}
}
async Task EnterFromMenu()
{
var channel=new NativeRelogChannel(pid,NativeRelogChannel.SupportedGameSha256,cancellation=>{cancellation.ThrowIfCancellationRequested();Verify();return Task.CompletedTask;},entryOnly:true);
var owner=Guid.NewGuid().ToString("N");string? pending=null,pendingState=null,lastIssuedState=null;var issued=DateTimeOffset.MinValue;var actions=new List<string>();
try
{
await channel.BeginAsync(owner,token);
while(true)
{
token.ThrowIfCancellationRequested();Verify();var frame=await channel.ReadAsync(owner,token);lastState=frame.State;
native=await pipe.CallAsync(new{op="session.status"},token);reads++;await State();
if(frame.Stable&&frame.State=="InGame"&&IdentityReady())break;
if(frame.State=="FailedToConnect")throw new IOException("Original game connection failed during same-process entry.");
if(DateTimeOffset.UtcNow-frame.ObservedAt>TimeSpan.FromMilliseconds(350)||frame.ObservedAt>DateTimeOffset.UtcNow.AddMilliseconds(100)){await Task.Delay(100,token);continue;}
if(pending is not null)
{
if(frame.LastRequestId==pending&&(frame.ActionApplied||frame.ActionFailed)){if(frame.ActionFailed)lastIssuedState=null;pending=null;}
else if(frame.State!=pendingState){pending=null;}
else if(DateTimeOffset.UtcNow-issued>TimeSpan.FromSeconds(3))throw new IOException("Original entry acknowledgement remains uncertain; no duplicate menu input sent.");
}
if(pending is null)
{
var action=NativeRelogWorkflow.ChooseAction(frame,"Solo",reachedMainMenu:true);
if(action is not null&&lastIssuedState!=frame.State)
{
pending=Guid.NewGuid().ToString("N");pendingState=frame.State;lastIssuedState=frame.State;issued=DateTimeOffset.UtcNow;
try{await channel.SubmitAsync(owner,pending,frame,action,token);actions.Add(action);}
catch(NativeSessionStateChangedException){pending=null;lastIssuedState=null;}
catch(IOException){}catch(OperationCanceledException)when(!token.IsCancellationRequested){}
}
}
await Task.Delay(100,token);
}
await File.WriteAllTextAsync(report+".same-process-entry.json",JsonSerializer.Serialize(new{passed=true,pid,started,actions,at=DateTimeOffset.UtcNow,newClient=false,originalEntryFailurePreserved=true},ToolboxSettings.JsonOptions),token);
}
finally{await channel.EndAsync(owner);}
}
Verify();await State(true);
var sentinel=Task.Run(async()=>
{
while(!token.IsCancellationRequested)
{
try
{
if(File.Exists(stopFile)){reason="Explicit test stop file detected.";stop.Cancel();return;}
Verify();await Task.Delay(250,token);
}
catch(OperationCanceledException)when(token.IsCancellationRequested){return;}
catch(Exception error){reason=error.Message;stop.Cancel();return;}
}
});
try
{
while(true)
{
token.ThrowIfCancellationRequested();Verify();
try
{
native=await pipe.CallAsync(new{op="session.status"},token);reads++;var state=native.Value.GetProperty("state").GetString()!;var next=native.Value.GetProperty("nextState").GetString()!;
var stateChanged=state!=lastState;lastState=state;
if(state=="FailedToConnect"){phase="original-connection-failed";reason="Original game reported FailedToConnect; no forced state transition sent.";await State(true);throw new IOException(reason);}
if(state==next&&state is "MainMenu" or "ContinueMenu"&&!attemptedEntry)
{attemptedEntry=true;phase="same-process-entry-only";await State(true);await EnterFromMenu();phase="waiting-independent-protection";}
if(state=="InGame"&&state==next&&IdentityReady()&&await ProtectionReady())
{
phase="testing-normal-space-075-zero";testStarted=true;reason=null;await State(true);
await DockingFixtureAcceptance.RunAsync(pid,fixtureReport,stopFile,token);
using var fixture=SharedJson(fixtureReport);passed=fixture.RootElement.GetProperty("passed").GetBoolean();phase=passed==true?"passed":"fixture-failed";await State(true);break;
}
await State(stateChanged);
}
catch(Exception error)when(!token.IsCancellationRequested&&!testStarted&&phase is "waiting-original-session" or "waiting-independent-protection"
&&error is OperationCanceledException or IOException)
{reason="Waiting for original session/health: "+error.Message;await State();}
await Task.Delay(1000,token);
}
}
catch(OperationCanceledException)when(token.IsCancellationRequested){phase="stopped";reason??="Explicit cancellation.";await State(true);}
catch(Exception error){phase=testStarted?"fixture-failed":"waiting-failed";reason=error.ToString();await State(true);throw;}
finally{stop.Cancel();await sentinel;}
}
}
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using XFEEDToolBox.Core.Data;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
using XFEEDToolBox.Core.Session;
internal static class DockingFixtureWait
{
// A continuously running, explicitly launched test workflow. No scheduler,
// new game process, process termination, OS input, or arbitrary mode change.
internal static async Task RunAsync(int pid,long started,int observerPid,long observerStarted,string root,string report)
{
root=Path.GetFullPath(root);report=Path.GetFullPath(report);
if(Path.GetDirectoryName(report)!=root||File.Exists(report))throw new IOException("Use a fresh report in the existing trade work directory.");
Directory.CreateDirectory(root);var stopFile=report+".stop";var fixtureReport=report+".fixture.json";
using var exclusive=new FileStream(Path.Combine(root,"docking-fixture-wait.lock"),FileMode.OpenOrCreate,FileAccess.ReadWrite,FileShare.None);
using var stop=new CancellationTokenSource();var token=stop.Token;
await using var log=new StreamWriter(new FileStream(report+".jsonl",FileMode.CreateNew,FileAccess.Write,FileShare.ReadWrite|FileShare.Delete));
var settings=ToolboxSettings.Load(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"XFEstudio","XFEEDToolBox","settings.json"));
await using var journal=new JournalMonitor(settings.JournalDirectory);await journal.StartAsync();var known=journal.State;
if(known.CommanderId.Length==0||known.CurrentShipId!=5||known.CargoCount!=0||known.CargoCapacity!=192||known.GameMode!="Solo")throw new InvalidDataException("Known empty Solo Corsair identity required for the dedicated test.");
var pipe=new NavigationPipeClient(pid);var began=DateTimeOffset.UtcNow;var phase="waiting-original-session";string? reason=null,lastLog=null,lastState=null;
JsonElement? native=null;var attemptedEntry=false;var testStarted=false;long reads=0;bool? passed=null;DateTimeOffset lastWritten=default;
void Verify()
{
using var game=Process.GetProcessById(pid);using var observer=Process.GetProcessById(observerPid);
if(game.HasExited||game.StartTime.ToUniversalTime().Ticks!=started||game.ProcessName!="EliteDangerous64"
||!string.Equals(game.MainModule?.FileName,settings.PluginGameExecutable,StringComparison.OrdinalIgnoreCase)
||observer.HasExited||observer.StartTime.ToUniversalTime().Ticks!=observerStarted||observer.ProcessName!="NativeAcceptance")
throw new InvalidOperationException("Bound game or independent observer changed; test workflow stopped without retargeting.");
}
static JsonDocument SharedJson(string path)
{using var stream=new FileStream(path,FileMode.Open,FileAccess.Read,FileShare.ReadWrite|FileShare.Delete);return JsonDocument.Parse(stream);}
bool IdentityReady()
{
var value=journal.State;
return value.InGame&&value.GameMode=="Solo"&&value.CommanderId==known.CommanderId&&value.CurrentShipId==5&&value.CargoCount==0&&value.CargoCapacity==192
&&value.LoadGameAt is {} load&&load.UtcTicks>=started-TimeSpan.FromSeconds(5).Ticks
&&!NavigationSafety.UnhealthyHull(value)&&!NavigationSafety.HasExternalThreat(value);
}
async Task State(bool force=false)
{
if(!force&&DateTimeOffset.UtcNow-lastWritten<TimeSpan.FromSeconds(10))return;
var value=new JsonObject{["phase"]=phase,["at"]=DateTimeOffset.UtcNow,["began"]=began,["processId"]=pid,["gameStartedUtcTicks"]=started,
["observerPid"]=observerPid,["observerStartedUtcTicks"]=observerStarted,["stopFile"]=stopFile,["fixtureReport"]=fixtureReport,["sessionReads"]=reads,
["testStarted"]=testStarted,["identityReady"]=IdentityReady(),["entryAttempted"]=attemptedEntry,["reason"]=reason,["clientCreated"]=false,["clientTerminated"]=false,
["oldEntryFailurePreserved"]=true,["osInputSent"]=false,["tradeOrdersSent"]=0};
if(native is {} frame)value["session"]=JsonNode.Parse(frame.GetRawText());
if(passed is {} result)value["passed"]=result;
var text=value.ToJsonString(ToolboxSettings.JsonOptions);await File.WriteAllTextAsync(report+".tmp",text,CancellationToken.None);File.Move(report+".tmp",report,true);lastWritten=DateTimeOffset.UtcNow;
var key=phase+"|"+lastState+"|"+reason;
if(key!=lastLog){lastLog=key;await log.WriteLineAsync(text);await log.FlushAsync();Console.WriteLine(phase+" · "+(lastState??"")+" · "+reason);}
}
async Task<bool> ProtectionReady()
{
try
{
Verify();using var registry=SharedJson(Path.Combine(root,"active-ship-protection.json"));var r=registry.RootElement;
if(r.GetProperty("gamePid").GetInt32()!=pid||r.GetProperty("gameStartedUtcTicks").GetInt64()!=started||r.GetProperty("observerPid").GetInt32()!=observerPid||r.GetProperty("observerStartedUtcTicks").GetInt64()!=observerStarted)throw new InvalidDataException("Independent protection registry changed.");
var witness=Path.GetFullPath(r.GetProperty("report").GetString()!)+".ready.json";
if(Path.GetDirectoryName(witness)!=root)throw new InvalidDataException("Independent witness moved outside the work directory.");
using var document=SharedJson(witness);var w=document.RootElement;var now=DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();var at=w.GetProperty("healthAtUnixMilliseconds").GetInt64();
if(!w.GetProperty("ready").GetBoolean()||w.GetProperty("gamePid").GetInt32()!=pid||w.GetProperty("gameStartedUtcTicks").GetInt64()!=started
||w.GetProperty("observerPid").GetInt32()!=observerPid||w.GetProperty("observerStartedUtcTicks").GetInt64()!=observerStarted||w.GetProperty("shipId").GetInt64()!=5||at>now+50||now-at>500)return false;
var health=await pipe.CallAsync(new{op="health.read"},token);var h=health.GetProperty("shipHealth");
return health.GetProperty("localPlayerShipVerified").GetBoolean()&&h.GetProperty("verified").GetBoolean()&&h.GetProperty("hullFraction").GetDouble()>=.95
&&DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()-h.GetProperty("observedAtUnixMilliseconds").GetInt64()<=500;
}
catch(Exception error)when(error is IOException or JsonException or KeyNotFoundException
||error is InvalidOperationException&&error.Message.StartsWith("Native main-ship health unavailable",StringComparison.Ordinal)){return false;}
}
async Task EnterFromMenu()
{
var channel=new NativeRelogChannel(pid,NativeRelogChannel.SupportedGameSha256,cancellation=>{cancellation.ThrowIfCancellationRequested();Verify();return Task.CompletedTask;},entryOnly:true);
var owner=Guid.NewGuid().ToString("N");string? pending=null,pendingState=null,lastIssuedState=null;var issued=DateTimeOffset.MinValue;var actions=new List<string>();
try
{
await channel.BeginAsync(owner,token);
while(true)
{
token.ThrowIfCancellationRequested();Verify();var frame=await channel.ReadAsync(owner,token);lastState=frame.State;
native=await pipe.CallAsync(new{op="session.status"},token);reads++;await State();
if(frame.Stable&&frame.State=="InGame"&&IdentityReady())break;
if(frame.State=="FailedToConnect")throw new IOException("Original game connection failed during same-process entry.");
if(DateTimeOffset.UtcNow-frame.ObservedAt>TimeSpan.FromMilliseconds(350)||frame.ObservedAt>DateTimeOffset.UtcNow.AddMilliseconds(100)){await Task.Delay(100,token);continue;}
if(pending is not null)
{
if(frame.LastRequestId==pending&&(frame.ActionApplied||frame.ActionFailed)){if(frame.ActionFailed)lastIssuedState=null;pending=null;}
else if(frame.State!=pendingState){pending=null;}
else if(DateTimeOffset.UtcNow-issued>TimeSpan.FromSeconds(3))throw new IOException("Original entry acknowledgement remains uncertain; no duplicate menu input sent.");
}
if(pending is null)
{
var action=NativeRelogWorkflow.ChooseAction(frame,"Solo",reachedMainMenu:true);
if(action is not null&&lastIssuedState!=frame.State)
{
pending=Guid.NewGuid().ToString("N");pendingState=frame.State;lastIssuedState=frame.State;issued=DateTimeOffset.UtcNow;
try{await channel.SubmitAsync(owner,pending,frame,action,token);actions.Add(action);}
catch(NativeSessionStateChangedException){pending=null;lastIssuedState=null;}
catch(IOException){}catch(OperationCanceledException)when(!token.IsCancellationRequested){}
}
}
await Task.Delay(100,token);
}
await File.WriteAllTextAsync(report+".same-process-entry.json",JsonSerializer.Serialize(new{passed=true,pid,started,actions,at=DateTimeOffset.UtcNow,newClient=false,originalEntryFailurePreserved=true},ToolboxSettings.JsonOptions),token);
}
finally{await channel.EndAsync(owner);}
}
Verify();await State(true);
var sentinel=Task.Run(async()=>
{
while(!token.IsCancellationRequested)
{
try
{
if(File.Exists(stopFile)){reason="Explicit test stop file detected.";stop.Cancel();return;}
Verify();await Task.Delay(250,token);
}
catch(OperationCanceledException)when(token.IsCancellationRequested){return;}
catch(Exception error){reason=error.Message;stop.Cancel();return;}
}
});
try
{
while(true)
{
token.ThrowIfCancellationRequested();Verify();
try
{
native=await pipe.CallAsync(new{op="session.status"},token);reads++;var state=native.Value.GetProperty("state").GetString()!;var next=native.Value.GetProperty("nextState").GetString()!;
var stateChanged=state!=lastState;lastState=state;
if(state=="FailedToConnect"){phase="original-connection-failed";reason="Original game reported FailedToConnect; no forced state transition sent.";await State(true);throw new IOException(reason);}
if(state==next&&state is "MainMenu" or "ContinueMenu"&&!attemptedEntry)
{attemptedEntry=true;phase="same-process-entry-only";await State(true);await EnterFromMenu();phase="waiting-independent-protection";}
if(state=="InGame"&&state==next&&IdentityReady()&&await ProtectionReady())
{
phase="testing-normal-space-075-zero";testStarted=true;reason=null;await State(true);
await DockingFixtureAcceptance.RunAsync(pid,fixtureReport,stopFile,token);
using var fixture=SharedJson(fixtureReport);passed=fixture.RootElement.GetProperty("passed").GetBoolean();phase=passed==true?"passed":"fixture-failed";await State(true);break;
}
await State(stateChanged);
}
catch(Exception error)when(!token.IsCancellationRequested&&!testStarted&&phase is "waiting-original-session" or "waiting-independent-protection"
&&error is OperationCanceledException or IOException)
{reason="Waiting for original session/health: "+error.Message;await State();}
await Task.Delay(1000,token);
}
}
catch(OperationCanceledException)when(token.IsCancellationRequested){phase="stopped";reason??="Explicit cancellation.";await State(true);}
catch(Exception error){phase=testStarted?"fixture-failed":"waiting-failed";reason=error.ToString();await State(true);throw;}
finally{stop.Cancel();await sentinel;}
}
}