Wire: write doubles and floats with .NET Framework's round-trip digits

The original ran Newtonsoft.Json on .NET Framework, whose "R" format writes
15 significant digits when they read back exactly and 17 otherwise (floats: 7,
else 9). Modern .NET writes the shortest round trip instead, so a value like
-0.066666670143604279 came out as -0.06666667014360428: equal when parsed,
different bytes. Every frame now goes through a converter that repeats the
Framework rule, with Newtonsoft's '.0' suffix and non-finite handling kept.
Goldens that encoded the modern digits are corrected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:44:34 +02:00
parent 82a518dcfb
commit 5a94e1cf0b
7 changed files with 148 additions and 7 deletions

View file

@ -0,0 +1,82 @@
using System.Globalization;
using Newtonsoft.Json;
namespace OpenAC.MosswartMassacre.Backend;
/// <summary>
/// Writes doubles and floats with the digits the original plugin wrote.
/// </summary>
/// <remarks>
/// <para>
/// Newtonsoft.Json writes a double as <c>value.ToString("R")</c> plus <c>.0</c>
/// when that has no decimal point. The original ran on .NET Framework, where
/// "R" meant: fifteen significant digits if they read back as the same
/// double, otherwise seventeen (a float: seven, otherwise nine). Modern .NET
/// prints the shortest text that reads back instead, so a value such as
/// -0.066666670143604279 would come out as -0.06666667014360428: the same
/// number, different bytes. This converter repeats the Framework rule.
/// </para>
/// <para>
/// Non-finite values keep Newtonsoft's own handling.
/// </para>
/// </remarks>
internal sealed class FrameworkNumberConverter : JsonConverter
{
public static FrameworkNumberConverter Instance { get; } = new();
public override bool CanRead => false;
public override bool CanConvert(Type objectType) =>
objectType == typeof(double) || objectType == typeof(double?)
|| objectType == typeof(float) || objectType == typeof(float?);
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
switch (value)
{
case null:
writer.WriteNull();
break;
case double d when double.IsFinite(d):
writer.WriteRawValue(EnsureDecimalPlace(FrameworkRoundTrip(d)));
break;
case float f when float.IsFinite(f):
writer.WriteRawValue(EnsureDecimalPlace(FrameworkRoundTrip(f)));
break;
case double d:
writer.WriteValue(d);
break;
case float f:
writer.WriteValue(f);
break;
default:
writer.WriteValue(value);
break;
}
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) =>
throw new NotSupportedException();
/// <summary>.NET Framework's "R" for a double: G15 if it reads back exactly, else G17.</summary>
internal static string FrameworkRoundTrip(double value)
{
string text = value.ToString("G15", CultureInfo.InvariantCulture);
if (double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == value)
return text;
return value.ToString("G17", CultureInfo.InvariantCulture);
}
/// <summary>.NET Framework's "R" for a float: G7 if it reads back exactly, else G9.</summary>
internal static string FrameworkRoundTrip(float value)
{
string text = value.ToString("G7", CultureInfo.InvariantCulture);
if (float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture) == value)
return text;
return value.ToString("G9", CultureInfo.InvariantCulture);
}
/// <summary>Newtonsoft's rule: a number with no point and no exponent gets ".0".</summary>
private static string EnsureDecimalPlace(string text) =>
text.Contains('.') || text.Contains('E') || text.Contains('e') ? text : text + ".0";
}

View file

@ -16,7 +16,14 @@ internal static class Wire
// A private serializer rather than JsonConvert.SerializeObject: that one
// picks up JsonConvert.DefaultSettings, which any code in this load
// context could change.
private static readonly JsonSerializer Serializer = JsonSerializer.Create();
private static readonly JsonSerializer Serializer = CreateSerializer();
private static JsonSerializer CreateSerializer()
{
JsonSerializer serializer = JsonSerializer.Create();
serializer.Converters.Add(FrameworkNumberConverter.Instance);
return serializer;
}
public static string Serialize(object? payload)
{