From 5a94e1cf0b526faa0cb0681a963a2f9c86e55039 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:44:34 +0200 Subject: [PATCH] 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) --- .../Backend/FrameworkNumberConverter.cs | 82 +++++++++++++++++++ src/OpenAC.MosswartMassacre/Backend/Wire.cs | 9 +- .../Backend/WireNumberTests.cs | 52 ++++++++++++ .../Inventory/InventoryGoldenTests.cs | 2 +- .../Radar/RadarTests.cs | 2 +- .../Sharing/VitalSharingTests.cs | 4 +- .../Streams/StreamGoldenTests.cs | 4 +- 7 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs diff --git a/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs b/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs new file mode 100644 index 0000000..68a4aff --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Backend/FrameworkNumberConverter.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using Newtonsoft.Json; + +namespace OpenAC.MosswartMassacre.Backend; + +/// +/// Writes doubles and floats with the digits the original plugin wrote. +/// +/// +/// +/// Newtonsoft.Json writes a double as value.ToString("R") plus .0 +/// 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. +/// +/// +/// Non-finite values keep Newtonsoft's own handling. +/// +/// +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(); + + /// .NET Framework's "R" for a double: G15 if it reads back exactly, else G17. + 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); + } + + /// .NET Framework's "R" for a float: G7 if it reads back exactly, else G9. + 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); + } + + /// Newtonsoft's rule: a number with no point and no exponent gets ".0". + private static string EnsureDecimalPlace(string text) => + text.Contains('.') || text.Contains('E') || text.Contains('e') ? text : text + ".0"; +} diff --git a/src/OpenAC.MosswartMassacre/Backend/Wire.cs b/src/OpenAC.MosswartMassacre/Backend/Wire.cs index 69061cb..1a56319 100644 --- a/src/OpenAC.MosswartMassacre/Backend/Wire.cs +++ b/src/OpenAC.MosswartMassacre/Backend/Wire.cs @@ -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) { diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs new file mode 100644 index 0000000..9928835 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/WireNumberTests.cs @@ -0,0 +1,52 @@ +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Tests.Backend; + +/// +/// Numbers written the way the original plugin (Newtonsoft.Json on .NET +/// Framework) wrote them. +/// +public sealed class WireNumberTests +{ + [Theory] + // 15 digits read back: the short form. + [InlineData(0.1, "0.1")] + [InlineData(75.0, "75.0")] + [InlineData(42.5, "42.5")] + [InlineData(-12.0, "-12.0")] + [InlineData(1e-05, "1E-05")] + // 15 digits do not read back: seventeen, not the shortest round trip. + [InlineData(-0.066666670143604279, "-0.066666670143604279")] + [InlineData((double)33.65f, "33.650001525878906")] + [InlineData(0.30000000000000004, "0.30000000000000004")] + public void Doubles_use_the_frameworks_round_trip_digits(double value, string expected) + { + Assert.Equal(expected, Wire.Serialize(value)); + Assert.Equal("{\"v\":" + expected + "}", Wire.Serialize(new { v = value })); + Assert.Equal("{\"v\":" + expected + "}", Wire.Serialize(new { v = (double?)value })); + Assert.Equal("{\"1\":" + expected + "}", Wire.Serialize(new Dictionary { [1] = value })); + } + + [Fact] + public void The_shortest_form_and_the_framework_form_can_differ() + { + // The case the converter exists for. + Assert.Equal("-0.06666667014360428", (-0.066666670143604279).ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + Assert.Equal("-0.066666670143604279", Wire.Serialize(-0.066666670143604279)); + } + + [Theory] + [InlineData(2.7115397f, "2.71153975")] + [InlineData(0.1f, "0.1")] + [InlineData(3f, "3.0")] + [InlineData(1.0000001f, "1.00000012")] + public void Floats_use_the_frameworks_round_trip_digits(float value, string expected) => + Assert.Equal("{\"v\":" + expected + "}", Wire.Serialize(new { v = value })); + + [Fact] + public void Nulls_and_non_finite_values_keep_newtonsofts_handling() + { + Assert.Equal("{\"v\":null}", Wire.Serialize(new { v = (double?)null })); + Assert.Equal("{\"v\":\"NaN\"}", Wire.Serialize(new { v = double.NaN })); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs index 263fffc..4e37554 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Inventory/InventoryGoldenTests.cs @@ -36,7 +36,7 @@ public sealed class InventoryGoldenTests // 600 - 200 (no material) - (240 active Incantation of Impenetrability - 20 Minor // Impenetrability bonus) = 180. private const string BreastplateRecord = - """{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.4000000059604645,"167772166":0.6000000238418579},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; + """{"HasIdData":true,"Id":-2147482877,"LastIdTime":""" + IdStamp + ""","ObjectClass":2,"Icon":4660,"ContainerId":1342177281,"BoolValues":{"69":true},"DoubleValues":{"167772160":1.2000000476837158,"167772161":1.0,"167772162":0.800000011920929,"167772163":0.20000000298023224,"167772164":1.5,"167772165":0.40000000596046448,"167772166":0.60000002384185791},"IntValues":{"5":800,"19":5000,"28":600,"265":5,"218103808":6044,"218103809":4660,"218103810":1342177281,"218103821":1024,"218103822":512,"218103824":1,"218103834":2,"218103835":18,"218103838":2,"218103843":1,"218103848":2},"StringValues":{"1":"Celdon Breastplate"},"ActiveSpells":[4407,1483],"Spells":[1486,2604],"Material":null,"Name":"Celdon Breastplate","EquipSkill":null,"Mastery":null,"ItemSet":"Noble Relic Set","ArmorLevel":600,"Imbue":null,"Tinks":-1,"MaxDamage":-1,"ElementalDmgBonus":-1,"Variance":-1.0,"DamageBonus":-1.0,"ElementalDamageVersusMonsters":-1.0,"AttackBonus":-1.0,"MeleeDefenseBonus":-1.0,"MagicDBonus":-1.0,"MissileDBonus":-1.0,"ManaCBonus":-1.0,"WieldLevel":-1,"SkillLevel":-1,"LoreRequirement":-1,"SalvageWorkmanship":-1.0,"Workmanship":-1,"Value":5000,"Burden":800,"DamRating":-1,"DamResistRating":-1,"CritRating":-1,"CritResistRating":-1,"CritDamRating":-1,"CritDamResistRating":-1,"HealBoostRating":-1,"VitalityRating":-1,"TotalRating":-1,"CalcedStartingArmorLevel":180,"CalcedBuffedTinkedDoT":-1.0,"CalcedBuffedMissileDamage":-1.0,"BuffedElementalDamageVersusMonsters":-1.0,"BuffedAttackBonus":-1.0,"BuffedMeleeDefenseBonus":-1.0,"BuffedManaCBonus":-1.0}"""; // Wielded: the container key and ContainerId are the wielder. The second header // (public flags 0x04000000) brings the underlay key, here 0. diff --git a/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs index dda6d6f..a9eaa65 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Radar/RadarTests.cs @@ -11,7 +11,7 @@ public sealed class RadarTests { private const string Stamp = "2026-09-25T08:30:15.1230000Z"; - private static string D(double value) => value.ToString("R", CultureInfo.InvariantCulture); + private static string D(double value) => OpenAC.MosswartMassacre.Backend.FrameworkNumberConverter.FrameworkRoundTrip(value); private static PluginWorldObject Landscape(uint id, string name, PluginObjectClass cls, PluginNavigationPosition position, uint wielder = 0) => new(id, 1u, name, cls, 0u, 0u, wielder) { IsLandscape = true, HasPosition = true, Position = position }; diff --git a/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs index 2785e4d..10ac642 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Sharing/VitalSharingTests.cs @@ -83,8 +83,8 @@ public sealed class VitalSharingTests Feature(harness).OnTick(); - string ew = Math.Round((double)33.65f, 7).ToString("R", CultureInfo.InvariantCulture); - string ns = Math.Round((double)42.55f, 7).ToString("R", CultureInfo.InvariantCulture); + string ew = OpenAC.MosswartMassacre.Backend.FrameworkNumberConverter.FrameworkRoundTrip(Math.Round((double)33.65f, 7)); + string ns = OpenAC.MosswartMassacre.Backend.FrameworkNumberConverter.FrameworkRoundTrip(Math.Round((double)42.55f, 7)); Assert.Equal( [ "{\"type\":\"share_vital_update\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\"," diff --git a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs index 68fe572..10dbbc0 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Streams/StreamGoldenTests.cs @@ -36,7 +36,7 @@ public sealed class StreamGoldenTests true); } - private static string D(float value) => ((double)value).ToString("R", CultureInfo.InvariantCulture); + private static string D(float value) => OpenAC.MosswartMassacre.Backend.FrameworkNumberConverter.FrameworkRoundTrip((double)value); private static PluginHarness LoggedIn(Action? arrange = null) { @@ -130,7 +130,7 @@ public sealed class StreamGoldenTests harness.Context.Settings.Current.CharTag = "tank"; harness.Clock.Advance(TimeSpan.FromMinutes(90)); - string json = Newtonsoft.Json.JsonConvert.SerializeObject(harness.Feature().Build()); + string json = OpenAC.MosswartMassacre.Backend.Wire.Serialize(harness.Feature().Build()); var frame = Newtonsoft.Json.Linq.JObject.Parse(json); string[] expectedOrder =