From e55902336fece8b1f54488e86db07e52e4e3015d Mon Sep 17 00:00:00 2001 From: CptMoore <39010654+cptmoore@users.noreply.github.com> Date: Sun, 8 Dec 2024 20:02:30 +0100 Subject: [PATCH] wip newtonsoft compatibility --- ModTek/Features/Logging/LogLevelExtension.cs | 34 +- ModTek/Features/Logging/LoggingSettings.cs | 5 +- ...JSONSerializationUtility_FromJSON_Patch.cs | 238 +++++++++++ ...ity_RehydrateObjectFromDictionary_Patch.cs | 4 +- ModTek/ModTek.csproj | 2 - ModTek/Util/HBSJsonUtils.cs | 379 ++++++++++++++++++ 6 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 ModTek/Features/Profiler/Patches/JSONSerializationUtility_FromJSON_Patch.cs diff --git a/ModTek/Features/Logging/LogLevelExtension.cs b/ModTek/Features/Logging/LogLevelExtension.cs index 88ab6ec..1ef582f 100644 --- a/ModTek/Features/Logging/LogLevelExtension.cs +++ b/ModTek/Features/Logging/LogLevelExtension.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using HBS.Logging; namespace ModTek.Features.Logging; @@ -7,7 +8,7 @@ internal static class LogLevelExtension { internal static string LogToString(LogLevel level) { - var eLogLevel = Convert(level); + var eLogLevel = ConvertHbsLogLevelToELogLevel(level); return eLogLevel switch // fast switch with static string, in order of most occuring { ELogLevels.Trace => "TRACE", @@ -23,10 +24,10 @@ internal static string LogToString(LogLevel level) internal static bool IsLogLevelGreaterThan(LogLevel loggerLevel, LogLevel messageLevel) { - return Convert(messageLevel) >= Convert(loggerLevel); + return ConvertHbsLogLevelToELogLevel(messageLevel) >= ConvertHbsLogLevelToELogLevel(loggerLevel); } - private static ELogLevels Convert(LogLevel level) + private static ELogLevels ConvertHbsLogLevelToELogLevel(LogLevel level) { var intLevel = (int)level; if (intLevel is >= (int)LogLevel.Debug and <= (int)LogLevel.Error) @@ -36,6 +37,33 @@ private static ELogLevels Convert(LogLevel level) return (ELogLevels)intLevel; } + internal static TraceLevel GetLevelFilter(ILog log) + { + if (log is not Logger.LogImpl logImpl) + { + return TraceLevel.Verbose; // either off or verbose, better too much + } + var effectiveLevel = logImpl.EffectiveLevel; + var eLogLevel = ConvertHbsLogLevelToELogLevel(effectiveLevel); + return ConvertELogLevelToTraceLevel(eLogLevel); + } + + private static TraceLevel ConvertELogLevelToTraceLevel(ELogLevels level) + { + level = (ELogLevels)((int)level / 10 * 10); + return level switch + { + ELogLevels.OFF => TraceLevel.Off, + ELogLevels.Fatal => TraceLevel.Error, + ELogLevels.Error => TraceLevel.Error, + ELogLevels.Warning => TraceLevel.Warning, + ELogLevels.Log => TraceLevel.Info, + ELogLevels.Debug => TraceLevel.Verbose, + ELogLevels.Trace => TraceLevel.Verbose, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) + }; + } + // log levels private enum ELogLevels { diff --git a/ModTek/Features/Logging/LoggingSettings.cs b/ModTek/Features/Logging/LoggingSettings.cs index 6f9900a..aef6cbe 100644 --- a/ModTek/Features/Logging/LoggingSettings.cs +++ b/ModTek/Features/Logging/LoggingSettings.cs @@ -97,7 +97,10 @@ internal class LoggingSettings [JsonProperty] internal const string MainLog_Description = "The main log."; [JsonProperty(Required = Required.Always)] - internal AppenderSettings MainLog = new(); + internal AppenderSettings MainLog = new() + { + Excludes = [ new FilterSettings { LoggerNames = ["HarmonyX" ] } ] + }; [JsonProperty(Required = Required.Always)] internal string MainLogFilePath = "battletech_log.txt"; diff --git a/ModTek/Features/Profiler/Patches/JSONSerializationUtility_FromJSON_Patch.cs b/ModTek/Features/Profiler/Patches/JSONSerializationUtility_FromJSON_Patch.cs new file mode 100644 index 0000000..0882512 --- /dev/null +++ b/ModTek/Features/Profiler/Patches/JSONSerializationUtility_FromJSON_Patch.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using BattleTech; +using fastJSON; +using HBS.Util; +using ModTek.Features.Logging; +using ModTek.Misc; +using ModTek.Util; + +namespace ModTek.Features.Profiler.Patches; + +[HarmonyPatch(typeof(MechDef), nameof(MechDef.RefreshChassis))] +internal static class MechDef_RefreshChassis_Patch +{ + internal static void Prefix(ref bool __runOriginal, MechDef __instance) + { + if (__instance.dataManager == null && __instance.Description == null) + { + __runOriginal = false; + } + } +} + +[HarmonyPatch(typeof(MechDef), nameof(MechDef.Chassis), MethodType.Setter)] +internal static class MechDef_set_Chassis_Patch +{ + [HarmonyPriority(Priority.Last)] + internal static void Prefix(MechDef __instance, ChassisDef value) + { + Log.Main.Trace?.Log($"MechDef.Chassis={value?.GetHashCode()}", new Exception()); + } +} + +[HarmonyPatch] +internal static class JSONSerializationUtility_FromJSON_Patch +{ + private static string basePath; + private static bool testNewton = true; + + public static bool Prepare() + { + if (testNewton) + { + basePath = Path.Combine(FilePaths.MergeCacheDirectory, "newton"); + if (Directory.Exists(basePath)) + { + Directory.Delete(basePath, true); + } + Directory.CreateDirectory(basePath); + } + return ModTek.Enabled; // && ModTek.Config.ProfilerEnabled; + } + + [HarmonyTargetMethods] + internal static IEnumerable TargetMethods() + { + var genericMethod = typeof(JSONSerializationUtility) + .GetMethods(BindingFlags.Public|BindingFlags.Static) + .Single(x => x.Name== nameof(JSONSerializationUtility.FromJSON) && x.GetParameters().Length == 2); + Log.Main.Trace?.Log("JSONSerializationUtility.FromJSON " + genericMethod); + + foreach ( + var jsonTemplated in + typeof(JSONSerializationUtility) + .Assembly + .GetTypes() + .Where(x => !x.IsAbstract && typeof(IJsonTemplated).IsAssignableFrom(x)) + ) { + Log.Main.Trace?.Log("IJsonTemplated " + jsonTemplated); + + MethodBase GetMethod(BindingFlags bindingAttr = BindingFlags.Default) + { + return jsonTemplated + .GetMethods(BindingFlags.Public | BindingFlags.Instance | bindingAttr) + .SingleOrDefault(x => x.Name == nameof(IJsonTemplated.FromJSON)); + } + + var fromJsonMethod = GetMethod(BindingFlags.DeclaredOnly); + if (fromJsonMethod == null) + { + fromJsonMethod = GetMethod(); + } + if (fromJsonMethod == null) + { + throw new Exception("WTF"); + } + if (fromJsonMethod.ContainsGenericParameters) + { + // TODO required by MDD indexer, goes through alot of jsons! + Log.Main.Trace?.Log(fromJsonMethod+ " ContainsGenericParameters"); + continue; + } + Log.Main.Trace?.Log("IJsonTemplated.FromJSON " + fromJsonMethod); + + yield return genericMethod.MakeGenericMethod(jsonTemplated); + // break; + } + } + + private static readonly MTStopwatch s_newton = new(); + private static readonly MTStopwatch s_stopwatch = new() + { + Callback = stats => + { + var newtonStats = s_newton.GetStats(); + Log.Main.Trace?.Log( + $""" + JSONSerializationUtility.FromJSON called {stats.Count} times, taking a total of {stats.TotalTime} with an average of {stats.AverageNanoseconds}ns. + Newton called {newtonStats.Count} times, taking a total of {newtonStats.TotalTime} with an average of {newtonStats.AverageNanoseconds}ns. + """ + ); + }, + CallbackForEveryNumberOfMeasurements = 100 + }; + + internal class MyState + { + internal MTStopwatch.Tracker Tracker; + internal long counter = Interlocked.Increment(ref s_counter); + + internal string nn; + internal string nh; + internal string hn; + internal string hh; + + internal void Save() + { + var differentPopulate = true; + var differentSerializers = false; // tag sets and dictionaries work differently + if ((differentSerializers && nn != nh) || (differentPopulate && hn != nn)) + { + File.WriteAllText(Path.Combine(basePath, $"{counter}_NN.json"), nn); + } + if ((differentSerializers && nn != nh) || (differentPopulate && nh != hh)) + { + File.WriteAllText(Path.Combine(basePath, $"{counter}_NH.json"), nh); + } + if ((differentSerializers && hn != hh) || (differentPopulate && hn != nn)) + { + File.WriteAllText(Path.Combine(basePath, $"{counter}_HN.json"), hn); + } + if ((differentSerializers && hn != hh) || (differentPopulate && nh != hh)) + { + File.WriteAllText(Path.Combine(basePath, $"{counter}_HH.json"), hh); + } + } + } + + private static long s_counter; + + [HarmonyPriority(Priority.First)] + public static void Prefix(object target, string json, ref MyState __state) + { + if (target == null) + { + return; + } + if (typeof(MechDef).Assembly != target.GetType().Assembly) + { + return; + } + + __state = new MyState(); + + if (testNewton) // && target.GetType() == typeof(MechDef) + { + s_newton.Start(); + try + { + var objectCopy = Activator.CreateInstance(target.GetType()); + + if (objectCopy is MechDef mechDef1) + { + Log.Main.Trace?.Log($"BWTF???? mechDef.dataManager: {mechDef1.dataManager?.GetHashCode()}"); + Log.Main.Trace?.Log($"BWTF???? mechDef._chassisDef: {mechDef1._chassisDef?.GetHashCode()}"); + } + + HBSJsonUtils.PopulateObject(objectCopy, json); + + if (objectCopy is MechDef mechDef2) + { + Log.Main.Trace?.Log($"AWTF???? mechDef.dataManager: {mechDef2.dataManager?.GetHashCode()}"); + Log.Main.Trace?.Log($"AWTF???? mechDef._chassisDef: {mechDef2._chassisDef?.GetHashCode()}"); + } + + __state.nn = HBSJsonUtils.SerializeObject(objectCopy); + __state.nh = JSON.ToJSON(objectCopy); + } + catch (Exception ex) + { + Log.Main.Error?.Log("Error Populating and Serializing " + target.GetType(), ex); + } + finally + { + s_newton.Stop(); + } + } + + __state.Tracker.Begin(); + } + + [HarmonyPriority(Priority.Last)] + public static void Postfix(object target, string json, ref MyState __state) + { + if (__state == null) + { + return; + } + + s_stopwatch.AddMeasurement(__state.Tracker.End()); + + if (testNewton) // && target.GetType() == typeof(MechDef) + { + try + { + __state.hn = HBSJsonUtils.SerializeObject(target); + __state.hh = JSON.ToJSON(target); + } + catch (Exception ex) + { + Log.Main.Error?.Log("Error Serializing " + target.GetType(), ex); + } + + try + { + __state.Save(); + } + catch (Exception ex) + { + Log.Main.Error?.Log("Error Saving JSONs " + target.GetType(), ex); + } + } + } +} \ No newline at end of file diff --git a/ModTek/Features/Profiler/Patches/JSONSerializationUtility_RehydrateObjectFromDictionary_Patch.cs b/ModTek/Features/Profiler/Patches/JSONSerializationUtility_RehydrateObjectFromDictionary_Patch.cs index ebb0ede..70cbc64 100644 --- a/ModTek/Features/Profiler/Patches/JSONSerializationUtility_RehydrateObjectFromDictionary_Patch.cs +++ b/ModTek/Features/Profiler/Patches/JSONSerializationUtility_RehydrateObjectFromDictionary_Patch.cs @@ -34,7 +34,7 @@ public static bool Prepare() CallbackForEveryNumberOfMeasurements = 1000 }; - [HarmonyPriority(Priority.First)] + [HarmonyPriority(Priority.Last)] public static void Prefix(string classStructure, ref MTStopwatch.Tracker __state) { if (string.IsNullOrEmpty(classStructure)) @@ -43,7 +43,7 @@ public static void Prefix(string classStructure, ref MTStopwatch.Tracker __state } } - [HarmonyPriority(Priority.Last)] + [HarmonyPriority(Priority.First)] public static void Postfix(string classStructure, ref MTStopwatch.Tracker __state) { if (string.IsNullOrEmpty(classStructure)) diff --git a/ModTek/ModTek.csproj b/ModTek/ModTek.csproj index 1ee843e..d4c8b86 100644 --- a/ModTek/ModTek.csproj +++ b/ModTek/ModTek.csproj @@ -96,12 +96,10 @@ - diff --git a/ModTek/Util/HBSJsonUtils.cs b/ModTek/Util/HBSJsonUtils.cs index 4cc739f..2c7568e 100644 --- a/ModTek/Util/HBSJsonUtils.cs +++ b/ModTek/Util/HBSJsonUtils.cs @@ -1,8 +1,22 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.IO; +using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; +using fastJSON; +using HBS.Collections; +using HBS.Logging; using HBS.Util; +using ModTek.Features.Logging; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Newtonsoft.Json.Utilities; namespace ModTek.Util; @@ -45,4 +59,369 @@ private static string FixHBSJsonCommas(string json) // add missing commas, this only fixes if there is a newline return s_fixMissingCommasInJson.Replace(json, "$1,\n$2"); } + + // might work, only slightly tested + internal static string SerializeObject(object target) + { + return JsonConvert.SerializeObject(target, s_jsonSerializerSettings); + } + + // does not work 100%, issues e.g. with MechDef and ChassisRefresh + // idea is to be able to replace JSONSerializationUtility_FromJSON + // not because its faster, its slower (1-2s -> 4-6s), but because we can then start using other frameworks as well + // one of the others might be faster, does not need to be json + internal static void PopulateObject(object target, string json) + { + var commentsStripped = JSONSerializationUtility.StripHBSCommentsFromJSON(json); + var commasAdded = FixHBSJsonCommas(commentsStripped); + JsonConvert.PopulateObject(commasAdded, target, s_jsonSerializerSettings); + } + + private static readonly JsonSerializerSettings s_jsonSerializerSettings = new() + { + ContractResolver = new FastJsonContractResolver(), + NullValueHandling = NullValueHandling.Ignore, + Converters = [ + new TagSetConverter(), + new FastJsonStringEnumConverter(), + new FastJsonDictionaryConverter(), + new DecimalJsonConverter(), + ], + // TraceWriter = new JsonTraceWriter(), + // SerializationBinder = new SerializationBinderAdapter(new DefaultSerializationBinder()) + }; + + private class JsonTraceWriter : ITraceWriter + { + public void Trace(TraceLevel level, string message, Exception ex) + { + if (level == TraceLevel.Off) + { + return; + } + + Log.Main.Log.LogAtLevel(ConvertLevel(level), message, ex); + } + + private static LogLevel ConvertLevel(TraceLevel level) + { + return level switch + { + TraceLevel.Error => LogLevel.Error, + TraceLevel.Warning => LogLevel.Warning, + TraceLevel.Info => LogLevel.Log, + TraceLevel.Verbose => LogLevel.Debug, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null) + }; + } + + public TraceLevel LevelFilter => LogLevelExtension.GetLevelFilter(Log.Main.Log); + } + + private class FastJsonContractResolver : DefaultContractResolver + { + private readonly Dictionary> _serializableMembersCache = new(); + protected override List GetSerializableMembers(Type objectType) + { + var members = new List(); + lock (_serializableMembersCache) + { + if (_serializableMembersCache.TryGetValue(objectType, out var serializableMembers)) + { + return serializableMembers; + } + } + + members.AddRange( + objectType.GetProperties( BindingFlags.Instance | BindingFlags.Public) + .Where(member => member.CanWrite && member.CanRead) + .Where(member => !member.IsDefined(typeof(JsonIgnore), false)) + ); + members.AddRange( + objectType.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic) + .Where(member => member.IsDefined(typeof(JsonSerialized), false)) + ); + + members.AddRange( + objectType.GetFields( BindingFlags.Instance | BindingFlags.Public) + .Where(member => !member.IsDefined(typeof(JsonIgnore), false)) + ); + members.AddRange( + objectType.GetFields(BindingFlags.Instance | BindingFlags.NonPublic) + .Where(member => !member.IsInitOnly) + .Where(member => member.IsDefined(typeof(JsonSerialized), false)) + ); + + // Order of properties & fields processing is different between fastJson+HBS compared to Newtonsoft.Json + // -> some setters are called in different order + // -> sometimes different things are null or not at a different time + // solutions: + // - emulate fastJson+HBS ordering? -> uses some kind of 1. properties 2. fields but not always + // - fix vanilla code to avoid needing setters at all? -> can break existing code + // - introduce explicit converters? -> similar to CustomPrewam; require mod support APIs to let mods fix themselves + Log.Main.Debug?.Log($"Found {members.Count} members for type {objectType.FullName}: {string.Join(",", members.Select(m => m.Name))}"); + + lock (_serializableMembersCache) + { + _serializableMembersCache[objectType] = members; + } + + return members; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + // if (member.DeclaringType != null && member.DeclaringType.IsAssignableFrom(typeof(MechDef))) + // { + // Log.Main.Debug?.Log($"Found member {member} on MechDef"); + // } + + var property = base.CreateProperty(member, memberSerialization); + property.Readable = true; + property.Writable = true; + property.Ignored = false; + property.ShouldSerialize = _ => true; + property.ShouldDeserialize = _ => true; + return property; + } + } + + private class FastJsonStringEnumConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + } + else + { + writer.WriteValue(value.ToString()); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var isNullable = ReflectionUtils.IsNullableType(objectType); + if (reader.TokenType == JsonToken.Null) + { + if (!isNullable) + throw JsonSerializationException.Create(reader, "Cannot convert null value to {0}.".FormatWith(CultureInfo.InvariantCulture, objectType)); + return null; + } + + var enumType = isNullable ? Nullable.GetUnderlyingType(objectType) : objectType; + if (enumType == null) + { + throw new InvalidCastException(); + } + + if (reader.TokenType == JsonToken.String) + { + try + { + var value = reader.Value.ToString(); + if (value.Length == 0 && isNullable) + { + return null; + } + return Enum.Parse(enumType, value, true); + } + catch (Exception ex) + { + throw JsonSerializationException.Create(reader, "Error converting value {0} to type '{1}'.".FormatWith(CultureInfo.InvariantCulture, MiscellaneousUtils.FormatValueForPrint(reader.Value), objectType), ex); + } + } + if (reader.TokenType == JsonToken.Integer) + { + var numericValue = (int)reader.Value; + return Enum.ToObject(enumType, numericValue); + } + + throw JsonSerializationException.Create(reader, "Unexpected token {0} when parsing enum.".FormatWith(CultureInfo.InvariantCulture, reader.TokenType)); + } + + public override bool CanConvert(Type objectType) + { + // copied from StringEnumConverter.CanConvert + return (ReflectionUtils.IsNullableType(objectType) ? Nullable.GetUnderlyingType(objectType) : objectType).IsEnum(); + } + } + + /* + * { "items": [ I ], "tagSetSourceFile": ... } + * to + * [ I ] + */ + private class TagSetConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value is TagSet tagSet) + { + writer.WriteStartArray(); + foreach (var item in tagSet.items) + { + writer.WriteValue(item); + } + writer.WriteEndArray(); + } + else + { + writer.WriteNull(); + } + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + JArray array; + switch (reader.TokenType) + { + case JsonToken.Null: + return null; + case JsonToken.StartObject: + var jObject = JObject.Load(reader); + if (!jObject.TryGetValue("items", out var token) || token is not JArray castArray) + { + return null; + } + array = castArray; + break; + case JsonToken.StartArray: + array = JArray.Load(reader); + break; + default: + return null; + } + List items = new(); + foreach (var item in array) + { + if (item.Type != JTokenType.String) + { + return null; + } + items.Add(item.ToString()); + } + return new TagSet(items); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(TagSet); + } + } + + /* + * [ { "k" : "A" , "v" : B } , { "k" : "C" , "v": D } ] + * to + * { "A" : B , "C" : D } + */ + private class FastJsonDictionaryConverter : JsonConverter + { + public override bool CanWrite => false; + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + var valueType = objectType.GetGenericArguments()[1]; + var intermediateDictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(string), valueType); + var intermediateDictionary = (IDictionary)Activator.CreateInstance(intermediateDictionaryType); + + if (reader.TokenType == JsonToken.StartArray) + { + var intermediateListItemType = typeof(FastJsonDictionaryArrayItem<>).MakeGenericType(valueType); + var intermediateListType = typeof(List<>).MakeGenericType(intermediateListItemType); + var intermediateList = (IList)Activator.CreateInstance(intermediateListType); + serializer.Populate(reader, intermediateList); + foreach (var item in intermediateList) + { + var traverse = Traverse.Create(item); + var key = traverse.Field("k").GetValue(); + var value = traverse.Field("v").GetValue(); + intermediateDictionary.Add(key, value); + } + } + else if (reader.TokenType == JsonToken.StartObject) + { + serializer.Populate(reader, intermediateDictionary); + } + + var keyType = objectType.GetGenericArguments()[0]; + if (keyType == typeof(string)) + { + return intermediateDictionary; + } + + var finalDictionary = (IDictionary)Activator.CreateInstance(objectType); + if (keyType.IsEnum) + { + foreach (DictionaryEntry pair in intermediateDictionary) + { + var key = Enum.Parse(keyType, (string)pair.Key, true); + finalDictionary.Add(key, pair.Value); + } + } + else + { + throw JsonSerializationException.Create(reader, $"Dictionary key type {keyType} is not supported."); + + } + return finalDictionary; + } + + public override bool CanConvert(Type objectType) + { + return typeof(IDictionary).IsAssignableFrom(objectType); + } + } + private class FastJsonDictionaryArrayItem + { + public string k; + public T v; + } + + // don't serialize after decimal points if those are 0 + private class DecimalJsonConverter : JsonConverter + { + public override bool CanRead => false; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(decimal) || objectType == typeof(float) || objectType == typeof(double); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteRawValue(Format(value)); + } + + private static string Format(object value) + { + // BT was first written for 32bit, therefore more floats exist + if (value is float floatValue) + { + return floatValue.ToString("G9", CultureInfo.InvariantCulture); + } + if (value is double doubleValue) + { + return doubleValue.ToString("G17", CultureInfo.InvariantCulture); + } + var formattableValue = value as IFormattable; + return formattableValue!.ToString("R", CultureInfo.InvariantCulture); + } + } } \ No newline at end of file