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 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(); 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;} } }