using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using XFEEliteDangerous.Client.Storage; namespace XFEEliteDangerous.Core; /// Confirmed upload-only fields, retained separately from the editable inventory. [JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] public sealed record NativeSavegameUploadState { public required int OnFootDistanceTravelled {get;init;} public required bool NearPlanet {get;init;} public JsonElement? OnboardingObjectives {get;init;} public bool? OnboardingObjectivesComplete {get;init;} public IReadOnlyDictionary VesselExtras {get;init;}=new Dictionary(); } /// /// PUT writer 23F8220/23F8FB0 sends a runtime delta, not the GET document. /// Validate and construct the whole candidate before changing the account. /// No credits, cargo, ship membership, market transactions or world creation are inferred. /// internal static class NativeSavegameUpload { private static readonly string[] Objectives=["ShipVendorAndTutorial","SuitWeaponsVendor","CreateLoadoutWithTerminal", "CompleteFirstMission","CommanderFirstShip","VisitShipVendor","FinishShipTutorial","VisitSuitWeaponsVendor", "PurchaseUtilitySuit","AccessTerminal","ApplyLoadout","GoToTurboLift","SelectShipHangarFloor","BoardShip", "AccessMissionBoardTerminal","AcceptMission"]; internal static NativeDataReply Handle(AccountState account,bool selected,byte[] body) { if(!selected)throw new NativeDataException(409,"Select a private commander before saving."); if(body.Length>1024*1024)throw new NativeDataException(413,"Native save upload exceeds the private limit."); var original=account.NativeSave??throw new NativeDataException(409,"Native save is not initialized."); var baseline=original.ExportCurrent(account); using var document=NativeJson.Parse(body);var root=document.RootElement; NativeJson.Fields(root,"commander","vessels","onboardingObjectives","onboardingObjectivesComplete"); var commander=NativeJson.Field(root,"commander"); NativeJson.Fields(commander,"vehicleDistanceTravelled","onFootDistanceTravelled","startupState","nearplanet","gender","atrLevel","markedWanted"); var profile=baseline.Profile with { VehicleDistanceTravelled=I32(commander,"vehicleDistanceTravelled"),StartupState=NativeJson.U32(commander,"startupState"), AtrLevel=NativeJson.U32(commander,"atrLevel"),MarkedWanted=NativeJson.Boolean(commander,"markedWanted"), Gender=commander.TryGetProperty("gender",out _)?NativeJson.Text(commander,"gender",1):baseline.Profile.Gender }; var extras=new NativeSavegameUploadState { OnFootDistanceTravelled=I32(commander,"onFootDistanceTravelled"),NearPlanet=NativeJson.Boolean(commander,"nearplanet"), OnboardingObjectives=root.TryGetProperty("onboardingObjectives",out var onboarding)?Onboarding(onboarding):baseline.UploadState?.OnboardingObjectives, OnboardingObjectivesComplete=root.TryGetProperty("onboardingObjectivesComplete",out _) ?NativeJson.Boolean(root,"onboardingObjectivesComplete"):baseline.UploadState?.OnboardingObjectivesComplete, VesselExtras=new Dictionary(baseline.UploadState?.VesselExtras??new Dictionary()) }; var entries=NativeJson.Field(root,"vessels"); if(entries.ValueKind!=JsonValueKind.Array||entries.GetArrayLength()>baseline.Snapshot.Vessels.Count) throw new NativeDataException(400,"Invalid native vessel delta collection."); var updates=new Dictionary(); foreach(var entry in entries.EnumerateArray()) { NativeJson.Fields(entry,"ownedshipid","parentshipid","prototype","location","state"); var id=NativeJson.U32(entry,"ownedshipid"); var ship=baseline.Snapshot.Vessels.SingleOrDefault(value=>value.Id==id) ??throw new NativeDataException(404,"Native upload references an unowned vessel."); if(updates.ContainsKey(id))throw new NativeDataException(400,"Duplicate native vessel delta."); if(entry.TryGetProperty("parentshipid",out _)&&NativeJson.U32(entry,"parentshipid")!=ship.ParentShipId) throw new NativeDataException(409,"Native vessel parent changes require a separate transaction."); // This resource label describes the already identified vessel. It is // not a catalogue ID and must not replace the server's ship definition. _=NativeJson.Text(entry,"prototype",256); var location=NativeJson.Field(entry,"location");NativeJson.Fields(location,"ua","landed"); NativeLocationStringValue position=ship.Location; if(location.TryGetProperty("ua",out _)) { if(!NativeLocationStringCodec.TryParse(NativeJson.Text(location,"ua",512),out position)) throw new NativeDataException(400,"Invalid native uploaded location."); if((position.Address&0x007f_ffff_ffff_ffffUL)!=profile.CurrentSystem) throw new NativeDataException(409,"A system transition requires its own confirmed system state."); } var extra=new JsonObject(); if(extras.VesselExtras.TryGetValue(id,out var previous))extra=JsonNode.Parse(previous.GetRawText())!.AsObject(); if(location.TryGetProperty("landed",out _))extra["landed"]=NativeJson.Boolean(location,"landed"); ship=ApplyState(ship,NativeJson.Field(entry,"state"),extra) with {Location=position, LastLocation=position==ship.Location?ship.LastLocation:ship.Location}; updates.Add(id,ship); ((Dictionary)extras.VesselExtras)[id]=JsonSerializer.SerializeToElement(extra,Wire.Json); } var vessels=baseline.Snapshot.Vessels.Select(ship=>updates.GetValueOrDefault(ship.Id,ship)).ToArray(); var active=vessels.Single(ship=>ship.Id==baseline.Snapshot.CurrentVesselId); var candidate=baseline with{Profile=profile,UploadState=extras, Snapshot=baseline.Snapshot with{Vessels=vessels,Location=active.Location}}; try { var inventory=candidate.CreateInventory(account,account.Commander.Revision); _=candidate.SerializeCurrent(withCommander(inventory)); if(JsonSerializer.Serialize(candidate,Wire.Json)==JsonSerializer.Serialize(baseline,Wire.Json))return new(200,""); if(account.Commander.Revision==long.MaxValue)throw new NativeDataException(409,"Native inventory revision exhausted."); inventory.Revision=account.Commander.Revision+1; account.NativeSave=candidate;account.Commander=inventory; // The native upload completion consumes the captured XML acknowledgement, // even though the upload itself and the GET save are JSON documents. return new(200,""); } catch(Exception error)when(error is ArgumentException or InvalidOperationException or OverflowException) {throw new NativeDataException(400,"Native save delta is inconsistent with the private state.");} AccountState withCommander(CommanderState value)=>new(){NativeUserId=account.NativeUserId,Commander=value}; } private static NativeShipSnapshot ApplyState(NativeShipSnapshot ship,JsonElement state,JsonObject extra) { if(state.ValueKind!=JsonValueKind.Object)throw new NativeDataException(400,"Expected native vessel state."); var groups=ship.Firegroups.ToDictionary(value=>value.Index);var capacitors=ship.Capacitors.ToDictionary(value=>value.Index); foreach(var field in state.EnumerateObject()) { var key=field.Name; switch(key) { case "cockpitBreached":ship=ship with{CockpitBreached=NativeJson.Boolean(state,key)};break; case "fuel.lvl":ship=ship with{Fuel=ship.Fuel with{Level=Quantity(state,key,ship.Fuel.Capacity)}};break; case "reserve.lvl":ship=ship with{Fuel=ship.Fuel with{ReserveLevel=Quantity(state,key,ship.Fuel.Capacity)}};break; case "health.hull":ship=ship with{Health=ship.Health with{HullMillion=Million(state,key)}};break; case "health.shield":ship=ship with{Health=ship.Health with{ShieldMillion=Million(state,key)}};break; case "health.integrity":ship=ship with{Health=ship.Health with{IntegrityMillion=Million(state,key)}};break; case "health.paintwork":ship=ship with{Health=ship.Health with{PaintworkMillion=Million(state,key)}};break; case "health.shieldup":ship=ship with{Health=ship.Health with{ShieldUp=NativeJson.Boolean(state,key)}};break; case "oxygenRemaining":ship=ship with{OxygenRemainingMilliseconds=NativeJson.U32(state,key)};break; case "radarRangeSelection":ship=ship with{RadarRangeSelection=NativeJson.U32(state,key)};break; case "weaponMode":ship=ship with{WeaponMode=NativeJson.U32(state,key)};break; case "firegroupSelected":case "health.caustic":case "health.scorch":case "fuel.superchargedFSD": extra[key]=NativeJson.U32(state,key);break; case "health.shielddamagetimer":extra[key]=Quantity(state,key,float.MaxValue);break; case "purchaseData":extra[key]=NativeJson.Text(state,key,32_768);break; default: var path=key.Split('.'); if(path.Length==3&&path[0] is "firegroups" or "capacitors"&&uint.TryParse(path[1],NumberStyles.None,CultureInfo.InvariantCulture,out var index)) { if(path[0]=="firegroups"&&index<8&&path[2] is "primary" or "secondary") { var group=groups.GetValueOrDefault(index,new(index,[],[])); var indices=Indices(NativeJson.Text(state,key,8192)); groups[index]=path[2]=="primary"?group with{Primary=indices}:group with{Secondary=indices};break; } if(path[0]=="capacitors"&&index<16&&path[2] is "energy" or "pips") { var capacitor=capacitors.GetValueOrDefault(index,new(index,0,null)); //23FA714 scales the absolute capacitor energy by //one million. It is not a normalized health ratio. capacitors[index]=path[2]=="energy"?capacitor with{EnergyMillion=NativeJson.U32(state,key)} :capacitor with{Pips=Byte(state,key)};break; } } if(key.StartsWith("modules.",StringComparison.Ordinal)) {ship=Module(ship,state,key);break;} throw new NativeDataException(501,"Native save contains an unsupported runtime branch: "+key); } } return ship with{Firegroups=groups.Values.OrderBy(value=>value.Index).ToArray(),Capacitors=capacitors.Values.OrderBy(value=>value.Index).ToArray()}; } private static NativeShipSnapshot Module(NativeShipSnapshot ship,JsonElement state,string key) { var slot=ship.Modules.Concat(ship.FixedModules).SingleOrDefault(value=>key.StartsWith("modules."+value.Name+".",StringComparison.Ordinal)); if(slot?.Module is not{} module)throw new NativeDataException(409,"Native save module is absent from the recorded fit."); var member=key[(9+slot.Name.Length)..]; module=member switch { "id"=>module with{Uid=NativeJson.U64(state,key)}, "health"=>module with{HealthMillion=Million(state,key)},"on"=>module with{On=NativeJson.Boolean(state,key)}, "priority"=>module with{Priority=NativeJson.U32(state,key)},"boost"=>module with{Boost=NativeJson.U32(state,key)}, "damageOverride"=>module with{DamageOverride=NativeJson.U32(state,key)}, "ammo.clip"=>module with{Ammo=new(NativeJson.U32(state,key),module.Ammo?.Hopper??0)}, "ammo.hopper"=>module with{Ammo=new(module.Ammo?.Clip??0,NativeJson.U32(state,key))}, _=>throw new NativeDataException(501,"Native save module field is not implemented: "+key) }; NativeSlotSnapshot Replace(NativeSlotSnapshot value)=>ReferenceEquals(value,slot)?value with{Module=module}:value; return ship with{Modules=ship.Modules.Select(Replace).ToArray(),FixedModules=ship.FixedModules.Select(Replace).ToArray()}; } internal static void Validate(NativeCommanderSave save) { if(save.UploadState is not{} upload)return; if(upload.OnboardingObjectives is{} objectives)_=Onboarding(objectives); if(upload.VesselExtras is null||upload.VesselExtras.Count>save.Snapshot.Vessels.Count)throw new ArgumentException("Invalid uploaded native runtime state."); foreach(var pair in upload.VesselExtras) { if(!save.Snapshot.Vessels.Any(ship=>ship.Id==pair.Key))throw new ArgumentException("Unowned uploaded native runtime state."); NativeJson.Fields(pair.Value,"landed","firegroupSelected","health.caustic","health.scorch","health.shielddamagetimer","fuel.superchargedFSD","purchaseData"); foreach(var field in pair.Value.EnumerateObject()) if(field.Name=="landed")_=NativeJson.Boolean(pair.Value,field.Name); else if(field.Name=="purchaseData")_=NativeJson.Text(pair.Value,field.Name,32_768); else if(field.Name=="health.shielddamagetimer")_=Quantity(pair.Value,field.Name,float.MaxValue); else _=NativeJson.U32(pair.Value,field.Name); } } internal static string Compose(string json,NativeCommanderSave save) { if(save.UploadState is not{} upload)return json; Validate(save);var root=JsonNode.Parse(json)!.AsObject();var commander=root["commander"]!.AsObject(); commander["onFootDistanceTravelled"]=upload.OnFootDistanceTravelled;commander["nearplanet"]=upload.NearPlanet; if(upload.OnboardingObjectives is{} objectives)root["onboardingObjectives"]=JsonNode.Parse(objectives.GetRawText()); if(upload.OnboardingObjectivesComplete is{} complete)root["onboardingObjectivesComplete"]=complete; foreach(var node in root["vessels"]!.AsArray()) { var ship=node!.AsObject();if(!upload.VesselExtras.TryGetValue(ship["id"]!.GetValue(),out var fields))continue; foreach(var field in fields.EnumerateObject()) { var target=ship;var path=field.Name=="landed"?new[]{"location","landed"}:field.Name.Split('.'); for(var i=0;i1024)throw new NativeDataException(400,"Native firegroup exceeds its bound."); var result=new uint[values.Length];for(var i=0;i=1024) throw new NativeDataException(400,"Invalid native firegroup index."); if(result.Distinct().Count()!=result.Length)throw new NativeDataException(400,"Duplicate native firegroup index.");return result; } private static int I32(JsonElement node,string key) {var value=NativeJson.Field(node,key);return value.ValueKind==JsonValueKind.Number&&value.TryGetInt32(out var number)?number:throw new NativeDataException(400,"Invalid native signed distance.");} private static uint Million(JsonElement node,string key) {var value=NativeJson.U32(node,key);return value<=1_000_000?value:throw new NativeDataException(400, "Native normalized state '"+key+"'="+value.ToString(CultureInfo.InvariantCulture)+" exceeds 1000000.");} private static uint Byte(JsonElement node,string key) {var value=NativeJson.U32(node,key);return value<=255?value:throw new NativeDataException(400,"Native capacitor pips exceed the stored byte.");} private static float Quantity(JsonElement node,string key,float maximum) { var field=NativeJson.Field(node,key); return field.ValueKind==JsonValueKind.Number&&field.TryGetSingle(out var value)&&float.IsFinite(value)&&value>=0&&value<=maximum ?value:throw new NativeDataException(400,"Invalid native runtime quantity."); } }