diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index 12958f03..65dea503 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -14,7 +14,7 @@ namespace GameCult.Caching.MessagePack; /// The canonical System.Text.Json shape of every public CultMath value type, the JSON twin of /// . Vectors, quaternions and colors are objects keyed by their /// field names ({"x":1,"y":2}), matrices are arrays of row objects, rect is -/// {"min":..,"max":..}, CultCellular is {"nearest":..,"edge":..,"id":..}, CultPhasor is {"cos":..,"sin":..}, SpanNoise is {"field":..,"curvature":..,"fold":..}, SpanPath is {"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}, SpanStage is {"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}, and CultSiteCells is {"nearest":..,"second":..,"nearestSite":..,"secondSite":..}. +/// {"min":..,"max":..}, CultCellular is {"nearest":..,"edge":..,"id":..}, CultPhasor is {"cosine":..,"sine":..} (reading also accepts the legacy cos/sin names), SpanNoise is {"field":..,"curvature":..,"fold":..}, SpanPath is {"midpoint":..,"velocity":..,"speed":..,"acceleration":..,"halfLength":..}, SpanStage is {"frequency":..,"stretch":..,"axis":..,"shift":..,"offset":..,"octaveStep":..}, and CultSiteCells is {"nearest":..,"second":..,"nearestSite":..,"secondSite":..}. /// Property names are fixed: PropertyNamingPolicy does not rename them, and /// PropertyNameCaseInsensitive makes reading case-insensitive. /// @@ -270,14 +270,24 @@ public static class CultMathJson { private readonly string[] names; private readonly byte[][] utf8Names; + private readonly string?[] legacyNames; + private readonly byte[]?[] legacyUtf8Names; private readonly JsonEncodedText[] encoded; private readonly Node[] children; private readonly int[] offsets; public Fields(params (string Name, Node Child)[] fields) + : this(Array.ConvertAll(fields, f => (f.Name, f.Child, (string?)null))) + { + } + + // A legacy name is matched on read only and maps to the same field as its current name. + public Fields(params (string Name, Node Child, string? Legacy)[] fields) { names = new string[fields.Length]; utf8Names = new byte[fields.Length][]; + legacyNames = new string?[fields.Length]; + legacyUtf8Names = new byte[]?[fields.Length]; encoded = new JsonEncodedText[fields.Length]; children = new Node[fields.Length]; offsets = new int[fields.Length]; @@ -285,6 +295,8 @@ public static class CultMathJson { names[i] = fields[i].Name; utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name); + legacyNames[i] = fields[i].Legacy; + legacyUtf8Names[i] = fields[i].Legacy is { } legacy ? Encoding.UTF8.GetBytes(legacy) : null; encoded[i] = JsonEncodedText.Encode(fields[i].Name); children[i] = fields[i].Child; offsets[i] = Count; @@ -339,13 +351,14 @@ public static class CultMathJson if (!ignoreCase) { for (var i = 0; i < utf8Names.Length; i++) - if (r.ValueTextEquals(utf8Names[i])) return i; + if (r.ValueTextEquals(utf8Names[i]) || (legacyUtf8Names[i] is { } legacy && r.ValueTextEquals(legacy))) return i; return -1; } var name = r.GetString()!; for (var i = 0; i < names.Length; i++) - if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase) + || (legacyNames[i] is { } legacy && string.Equals(legacy, name, StringComparison.OrdinalIgnoreCase))) return i; return -1; } } @@ -481,7 +494,7 @@ public static class CultMathJson Add(UIntScalar.Instance, new Fields(("state", Val())), a => new Random { state = a[0] }, (v, a) => a[0] = v.state), - // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cos, sin}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}. + // Compositions: matrices are arrays of row objects, rect is {min, max}, CultCellular is {nearest, edge, id}, CultPhasor is {cosine, sine}, SpanNoise is {field, curvature, fold}, SpanPath is {midpoint, velocity, speed, acceleration, halfLength}, SpanStage is {frequency, stretch, axis, shift, offset, octaveStep}, CultSiteCells is {nearest, second, nearestSite, secondSite}. Add(f, new Rows(2, Xy()), a => new float2x2(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v[0].x; a[1] = v[0].y; a[2] = v[1].x; a[3] = v[1].y; }), @@ -507,7 +520,7 @@ public static class CultMathJson a[4] = v.edge.x; a[5] = v.edge.y; a[6] = v.edge.z; a[7] = v.edge.w; a[8] = v.id; }), - Add(f, new Fields(("cos", Xyzw()), ("sin", Xyzw())), + Add(f, new Fields(("cosine", Xyzw(), "cos"), ("sine", Xyzw(), "sin")), a => new CultPhasor(new float4(a[0], a[1], a[2], a[3]), new float4(a[4], a[5], a[6], a[7])), (v, a) => { diff --git a/src/GameCult.Caching.MessagePack/CultMathResolver.cs b/src/GameCult.Caching.MessagePack/CultMathResolver.cs index 02ee980c..048b7f87 100644 --- a/src/GameCult.Caching.MessagePack/CultMathResolver.cs +++ b/src/GameCult.Caching.MessagePack/CultMathResolver.cs @@ -180,7 +180,7 @@ public sealed class CultMathResolver : IFormatterResolver return value; }), - // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cos, sin], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite]. + // Compositions: matrices are rows, rect is [min, max], CultCellular is [nearest, edge, id], CultPhasor is [cosine, sine], SpanNoise is [field, curvature, fold], SpanPath is [midpoint, velocity, speed, acceleration, halfLength], SpanStage is [frequency, stretch, axis, shift, offset, octaveStep], CultSiteCells is [nearest, second, nearestSite, secondSite]. Shape((ref MessagePackWriter w, float2x2 v) => { w.WriteArrayHeader(2); PutFloat2(ref w, v[0]); PutFloat2(ref w, v[1]); }, (ref MessagePackReader r) => { diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index 4ebd552b..ef8ea3d6 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -44,7 +44,7 @@ namespace GameCult.Caching.Tests Json(new CultCellular(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f), 0.25f)).Should().Be( """{"nearest":{"x":1,"y":2,"z":3,"w":4},"edge":{"x":5,"y":6,"z":7,"w":8},"id":0.25}"""); Json(new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f))).Should().Be( - """{"cos":{"x":1,"y":2,"z":3,"w":4},"sin":{"x":5,"y":6,"z":7,"w":8}}"""); + """{"cosine":{"x":1,"y":2,"z":3,"w":4},"sine":{"x":5,"y":6,"z":7,"w":8}}"""); Json(new SpanNoise(new float4(1f, 2f, 3f, 4f), 0.25f, 0.5f)).Should().Be( """{"field":{"x":1,"y":2,"z":3,"w":4},"curvature":0.25,"fold":0.5}"""); Json(new SpanPath { midpoint = new float3(1f, 2f, 3f), velocity = new float3(4f, 5f, 6f), speed = 7f, acceleration = 8f, halfLength = 9f }).Should().Be( @@ -92,6 +92,55 @@ namespace GameCult.Caching.Tests Parse("null").Should().BeNull(); } + [Test] + public void Reading_AcceptsTheLegacyPhasorNames() + { + // The fixture is what the 0.4.1-era encoder wrote: keys cos/sin. + var path = System.IO.Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "cultphasor-json-legacy.json"); + var legacy = Parse(System.IO.File.ReadAllText(path)); + var current = new[] + { + new CultPhasor(new float4(1f, 2f, 3f, 4f), new float4(5f, 6f, 7f, 8f)), + math.phacelle(new float3(0.3f, 1.7f, -2.2f), new float3(6.2831853f, 0f, 0f), 0.25f, 0.5f), + }; + legacy.Should().HaveCount(2); + for (var i = 0; i < 2; i++) + { + BitsOf(legacy[i]).Should().Equal(BitsOf(current[i])); + BitsOf(Parse(Json(current[i]))).Should().Equal(BitsOf(current[i])); + } + + var caseInsensitive = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }.AddCultMathConverters(); + BitsOf(JsonSerializer.Deserialize("""{"COS":{"x":1,"y":2,"z":3,"w":4},"Sin":{"x":5,"y":6,"z":7,"w":8}}""", caseInsensitive)) + .Should().Equal(BitsOf(current[0])); + } + + [Test] + public void Reading_RefusesALegacyAndCurrentNameTogether() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var strict = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(strict, false); + Action both = () => JsonSerializer.Deserialize("""{"cos":{"x":1},"cosine":{"x":2}}""", strict); + both.Should().Throw().WithMessage("*appears more than once*"); + Action reversed = () => JsonSerializer.Deserialize("""{"sine":{"x":1},"sin":{"x":2}}""", strict); + reversed.Should().Throw().WithMessage("*appears more than once*"); + + // With duplicates allowed the later property replaces the earlier one, as for any repeated name. + Parse("""{"cos":{"x":1,"y":9},"cosine":{"x":2}}""").cosine.Should().Be(new float4(2f, 0f, 0f, 0f)); + } + + [Test] + public void Writing_NeverEmitsLegacyNames() + { + using var document = JsonDocument.Parse(Json(new CultPhasor(new float4(1f, 1f, 1f, 1f), new float4(2f, 2f, 2f, 2f)))); + document.RootElement.EnumerateObject().Select(p => p.Name).Should().Equal("cosine", "sine"); + } + + private static int[] BitsOf(CultPhasor v) => new[] { v.cosine.x, v.cosine.y, v.cosine.z, v.cosine.w, v.sine.x, v.sine.y, v.sine.z, v.sine.w } + .Select(BitConverter.SingleToInt32Bits).ToArray(); + [Test] public void Reading_RefusesShapesThatAreNotTheType() {