using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
internal static class FsdCalibration
{
public static async Task<bool> RunAsync(int pid,string profilePath,string reportPath,string mode)
{
if(mode is not ("cancel" or "timeout"))throw new ArgumentException("Expected cancel or timeout calibration.");
if(File.Exists(reportPath)||File.Exists(reportPath+".jsonl"))throw new IOException("Use a new calibration evidence path.");
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(reportPath))!);
using var profile=JsonDocument.Parse(await File.ReadAllTextAsync(profilePath));
var image=Path.GetFullPath(profile.RootElement.GetProperty("image").GetString()!);
var hash=profile.RootElement.GetProperty("sha256").GetString();
await using(var stream=File.OpenRead(image))
if(Convert.ToHexStringLower(await SHA256.HashDataAsync(stream))!=hash)throw new InvalidDataException("Game build changed.");
using var game=Process.GetProcessById(pid);
if(!string.Equals(game.MainModule?.FileName,image,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("Selected game process changed.");
var started=game.StartTime.ToUniversalTime().Ticks;
var pipe=new NavigationPipeClient(pid);var owner=Guid.NewGuid().ToString("N");
await using var events=new StreamWriter(new FileStream(reportPath+".jsonl",FileMode.CreateNew,FileAccess.Write,FileShare.Read));
var clock=Stopwatch.StartNew();var frames=0;var peak=0.0;var sawCharging=false;var sawFilled=false;
var stopped=false;var released=false;var unexpectedJump=false;string? failure=null;double? deadlineToCancel=null;
double? chargingAt=null,filledAt=null,stopRenewAt=null,cancelAt=null;var commands=new List<object>();
async Task Log(object row){await events.WriteLineAsync(JsonSerializer.Serialize(row));await events.FlushAsync();}
async Task<JsonElement> Read()
{
using var current=Process.GetProcessById(pid);
if(current.StartTime.ToUniversalTime().Ticks!=started||!string.Equals(current.MainModule?.FileName,image,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("Game process identity changed.");
var frame=await pipe.CallAsync(new{op="read"});
if(!frame.GetProperty("inGame").GetBoolean())throw new InvalidOperationException("The ship is outside the active game.");
peak=Math.Max(peak,frame.GetProperty("heatPercent").GetDouble());
return frame;
}
async Task Command(string desired,JsonElement frame)
{
var result=await pipe.CallAsync(new{op="calibrate.fsd",owner,sequence=frame.GetProperty("sequence").GetInt64(),ttlMilliseconds=180,desired,heatCeilingPercent=90});
var row=new{kind="command",seconds=clock.Elapsed.TotalSeconds,desired,result};commands.Add(row);await Log(row);
}
try
{
var description=await pipe.CallAsync(new{op="describe"});
if(description.GetProperty("processId").GetInt32()!=pid||description.GetProperty("gameSha256").GetString()!=hash||description.GetProperty("version").GetString()!="0.3.1-dev")throw new InvalidDataException("Native calibration protocol mismatch.");
await Log(new{kind="description",description});
var statusPath=Path.Combine(new ToolboxSettings().JournalDirectory,"Status.json");
using var status=JsonDocument.Parse(await File.ReadAllTextAsync(statusPath));
var flags=status.RootElement.GetProperty("Flags").GetUInt64();
if((flags&(1UL|16UL|1048576UL|2097152UL))!=0||status.RootElement.GetProperty("GuiFocus").GetInt32()!=0)throw new InvalidOperationException("Calibration requires a stationary, undocked ship in normal space without danger or UI focus.");
var initial=await Read();
if(initial.GetProperty("speedMetresPerSecond").GetDouble()>=1||initial.GetProperty("heatPercent").GetDouble()>=60)throw new InvalidOperationException("The ship is moving or too hot for this calibration.");
var before=await pipe.CallAsync(new{op="fsd.describe"});
if(!before.GetProperty("fresh").GetBoolean()||before.GetProperty("state").GetInt32()!=2)throw new InvalidOperationException("Native FSD telemetry is not fresh and idle.");
await Command("beginSupercruise",initial);
while(clock.Elapsed.TotalSeconds<35)
{
var frame=await Read();var fsd=await pipe.CallAsync(new{op="fsd.describe"});var elapsed=clock.Elapsed.TotalSeconds;
frames++;await Log(new{kind="sample",seconds=elapsed,frame,fsd});
var state=fsd.GetProperty("state").GetInt32();var owned=fsd.GetProperty("ownedCharging").GetBoolean();
if(fsd.GetProperty("beginRejected").GetBoolean())throw new InvalidOperationException("The original game FSD eligibility checks rejected the input.");
if(state==4){unexpectedJump=true;throw new InvalidOperationException("FSD entered a jump before the holding test was complete.");}
if(state==3){sawCharging=true;chargingAt??=elapsed;}
var full=state==3&&fsd.GetProperty("duration").GetDouble()>0&&fsd.GetProperty("clock").GetDouble()>=fsd.GetProperty("duration").GetDouble();
if(full){sawFilled=true;filledAt??=elapsed;}
var stopWanted=mode=="timeout"&&chargingAt is {} at&&elapsed-at>=1||mode=="cancel"&&filledAt is {} filled&&elapsed-filled>=2;
if(peak>=89)throw new InvalidOperationException("Calibration reached its heat bound; cancelling owned charging.");
if(stopWanted&&cancelAt is null)
{
cancelAt=elapsed;
if(mode=="cancel")await Command("cancel",frame);else stopRenewAt=elapsed;
}
if(cancelAt is {} cancelled&&!owned&&state!=3)
{
stopped=true;deadlineToCancel=elapsed-cancelled;break;
}
if(cancelAt is null||mode=="cancel")await Command("keep",frame);
if(cancelAt is {} timeout&&elapsed-timeout>2)throw new TimeoutException("Owned charging did not cancel within two seconds.");
await Task.Delay(50);
}
if(!sawCharging||!stopped||mode=="cancel"&&!sawFilled)throw new InvalidOperationException("Charging/holding/cancellation acceptance was not reached.");
}
catch(Exception error){failure=error.ToString();}
finally
{
var releaseDeadline=Stopwatch.StartNew();
try
{
while(releaseDeadline.Elapsed<TimeSpan.FromSeconds(2))
{
var result=await pipe.CallAsync(new{op="release",owner});await Log(new{kind="release",result});
if(result.GetProperty("released").GetBoolean()){released=true;break;}
await Task.Delay(50);
}
if(!released)failure=(failure??"")+" Native release was not confirmed.";
}
catch(Exception error){failure=(failure??"")+" Release: "+error.Message;}
}
var passed=failure is null&&sawCharging&&stopped&&released&&!unexpectedJump&&(mode=="timeout"||sawFilled);
await File.WriteAllTextAsync(reportPath,JsonSerializer.Serialize(new{mode,pid,gameSha256=hash,frames,maximumHeatPercent=peak,sawCharging,sawFilled,stopped,released,unexpectedJump,
stopRenewAt,cancellationSeconds=deadlineToCancel,passed,failure,commands,screenUse=false,osInputSent=false,completeAutomaticNavigationAccepted=false},new JsonSerializerOptions{WriteIndented=true}));
Console.WriteLine(JsonSerializer.Serialize(new{mode,passed,sawCharging,sawFilled,stopped,released,maximumHeatPercent=peak,cancellationSeconds=deadlineToCancel,failure,reportPath}));
return passed;
}
}
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using XFEEDToolBox.Core.Models;
using XFEEDToolBox.Core.Navigation;
internal static class FsdCalibration
{
public static async Task<bool> RunAsync(int pid,string profilePath,string reportPath,string mode)
{
if(mode is not ("cancel" or "timeout"))throw new ArgumentException("Expected cancel or timeout calibration.");
if(File.Exists(reportPath)||File.Exists(reportPath+".jsonl"))throw new IOException("Use a new calibration evidence path.");
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(reportPath))!);
using var profile=JsonDocument.Parse(await File.ReadAllTextAsync(profilePath));
var image=Path.GetFullPath(profile.RootElement.GetProperty("image").GetString()!);
var hash=profile.RootElement.GetProperty("sha256").GetString();
await using(var stream=File.OpenRead(image))
if(Convert.ToHexStringLower(await SHA256.HashDataAsync(stream))!=hash)throw new InvalidDataException("Game build changed.");
using var game=Process.GetProcessById(pid);
if(!string.Equals(game.MainModule?.FileName,image,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("Selected game process changed.");
var started=game.StartTime.ToUniversalTime().Ticks;
var pipe=new NavigationPipeClient(pid);var owner=Guid.NewGuid().ToString("N");
await using var events=new StreamWriter(new FileStream(reportPath+".jsonl",FileMode.CreateNew,FileAccess.Write,FileShare.Read));
var clock=Stopwatch.StartNew();var frames=0;var peak=0.0;var sawCharging=false;var sawFilled=false;
var stopped=false;var released=false;var unexpectedJump=false;string? failure=null;double? deadlineToCancel=null;
double? chargingAt=null,filledAt=null,stopRenewAt=null,cancelAt=null;var commands=new List<object>();
async Task Log(object row){await events.WriteLineAsync(JsonSerializer.Serialize(row));await events.FlushAsync();}
async Task<JsonElement> Read()
{
using var current=Process.GetProcessById(pid);
if(current.StartTime.ToUniversalTime().Ticks!=started||!string.Equals(current.MainModule?.FileName,image,StringComparison.OrdinalIgnoreCase))throw new InvalidDataException("Game process identity changed.");
var frame=await pipe.CallAsync(new{op="read"});
if(!frame.GetProperty("inGame").GetBoolean())throw new InvalidOperationException("The ship is outside the active game.");
peak=Math.Max(peak,frame.GetProperty("heatPercent").GetDouble());
return frame;
}
async Task Command(string desired,JsonElement frame)
{
var result=await pipe.CallAsync(new{op="calibrate.fsd",owner,sequence=frame.GetProperty("sequence").GetInt64(),ttlMilliseconds=180,desired,heatCeilingPercent=90});
var row=new{kind="command",seconds=clock.Elapsed.TotalSeconds,desired,result};commands.Add(row);await Log(row);
}
try
{
var description=await pipe.CallAsync(new{op="describe"});
if(description.GetProperty("processId").GetInt32()!=pid||description.GetProperty("gameSha256").GetString()!=hash||description.GetProperty("version").GetString()!="0.3.1-dev")throw new InvalidDataException("Native calibration protocol mismatch.");
await Log(new{kind="description",description});
var statusPath=Path.Combine(new ToolboxSettings().JournalDirectory,"Status.json");
using var status=JsonDocument.Parse(await File.ReadAllTextAsync(statusPath));
var flags=status.RootElement.GetProperty("Flags").GetUInt64();
if((flags&(1UL|16UL|1048576UL|2097152UL))!=0||status.RootElement.GetProperty("GuiFocus").GetInt32()!=0)throw new InvalidOperationException("Calibration requires a stationary, undocked ship in normal space without danger or UI focus.");
var initial=await Read();
if(initial.GetProperty("speedMetresPerSecond").GetDouble()>=1||initial.GetProperty("heatPercent").GetDouble()>=60)throw new InvalidOperationException("The ship is moving or too hot for this calibration.");
var before=await pipe.CallAsync(new{op="fsd.describe"});
if(!before.GetProperty("fresh").GetBoolean()||before.GetProperty("state").GetInt32()!=2)throw new InvalidOperationException("Native FSD telemetry is not fresh and idle.");
await Command("beginSupercruise",initial);
while(clock.Elapsed.TotalSeconds<35)
{
var frame=await Read();var fsd=await pipe.CallAsync(new{op="fsd.describe"});var elapsed=clock.Elapsed.TotalSeconds;
frames++;await Log(new{kind="sample",seconds=elapsed,frame,fsd});
var state=fsd.GetProperty("state").GetInt32();var owned=fsd.GetProperty("ownedCharging").GetBoolean();
if(fsd.GetProperty("beginRejected").GetBoolean())throw new InvalidOperationException("The original game FSD eligibility checks rejected the input.");
if(state==4){unexpectedJump=true;throw new InvalidOperationException("FSD entered a jump before the holding test was complete.");}
if(state==3){sawCharging=true;chargingAt??=elapsed;}
var full=state==3&&fsd.GetProperty("duration").GetDouble()>0&&fsd.GetProperty("clock").GetDouble()>=fsd.GetProperty("duration").GetDouble();
if(full){sawFilled=true;filledAt??=elapsed;}
var stopWanted=mode=="timeout"&&chargingAt is {} at&&elapsed-at>=1||mode=="cancel"&&filledAt is {} filled&&elapsed-filled>=2;
if(peak>=89)throw new InvalidOperationException("Calibration reached its heat bound; cancelling owned charging.");
if(stopWanted&&cancelAt is null)
{
cancelAt=elapsed;
if(mode=="cancel")await Command("cancel",frame);else stopRenewAt=elapsed;
}
if(cancelAt is {} cancelled&&!owned&&state!=3)
{
stopped=true;deadlineToCancel=elapsed-cancelled;break;
}
if(cancelAt is null||mode=="cancel")await Command("keep",frame);
if(cancelAt is {} timeout&&elapsed-timeout>2)throw new TimeoutException("Owned charging did not cancel within two seconds.");
await Task.Delay(50);
}
if(!sawCharging||!stopped||mode=="cancel"&&!sawFilled)throw new InvalidOperationException("Charging/holding/cancellation acceptance was not reached.");
}
catch(Exception error){failure=error.ToString();}
finally
{
var releaseDeadline=Stopwatch.StartNew();
try
{
while(releaseDeadline.Elapsed<TimeSpan.FromSeconds(2))
{
var result=await pipe.CallAsync(new{op="release",owner});await Log(new{kind="release",result});
if(result.GetProperty("released").GetBoolean()){released=true;break;}
await Task.Delay(50);
}
if(!released)failure=(failure??"")+" Native release was not confirmed.";
}
catch(Exception error){failure=(failure??"")+" Release: "+error.Message;}
}
var passed=failure is null&&sawCharging&&stopped&&released&&!unexpectedJump&&(mode=="timeout"||sawFilled);
await File.WriteAllTextAsync(reportPath,JsonSerializer.Serialize(new{mode,pid,gameSha256=hash,frames,maximumHeatPercent=peak,sawCharging,sawFilled,stopped,released,unexpectedJump,
stopRenewAt,cancellationSeconds=deadlineToCancel,passed,failure,commands,screenUse=false,osInputSent=false,completeAutomaticNavigationAccepted=false},new JsonSerializerOptions{WriteIndented=true}));
Console.WriteLine(JsonSerializer.Serialize(new{mode,passed,sawCharging,sawFilled,stopped,released,maximumHeatPercent=peak,cancellationSeconds=deadlineToCancel,failure,reportPath}));
return passed;
}
}