fix(json): Rectangle3DConverter loses a z-level on write (#2506)

## Summary

`Rectangle3DConverter.Write` corrupts a rectangle's Z range for certain bounds. The omit-z guard was:

```csharp
var writeZ = value.Start.Z is > sbyte.MinValue and < sbyte.MaxValue
          || value.End.Z   is > sbyte.MinValue and < sbyte.MaxValue;
```

This drops `z1`/`z2` whenever **both** Z bounds sit at/outside the sbyte extremes — but `Read` reconstructs absent Z as exactly `z1 = -128, z2 = 127` (depth 255). So any rectangle that trips the omit condition without *being* that sentinel round-trips to depth 255 and is corrupted.

The concrete case: a **homeRange-style** bound `Start.Z = -128, End.Z = 128` (depth 256, the full vertical range used by spawners) gets `z1`/`z2` omitted on write, then reads back as depth **255** — silently losing the top z-level on every re-serialize.

(Spotted while working on #2505; spawners now prefer the `homeRange` form so most square bounds avoid this path, but any non-square `spawnBounds` or other `Rectangle3D` JSON is affected.)

## Fix

Omit Z only for the exact sentinel `Read` produces (`z1 == -128 && z2 == 127`); write it for anything else:

```csharp
var writeZ = value.Start.Z != sbyte.MinValue || value.End.Z != sbyte.MaxValue;
```

`Read` is unchanged, so existing `regions.json` rectangles that omit Z keep loading identically.

## Tests

New `Rectangle3DConverterTests`: round-trips the homeRange depth-256 case, the depth-255 sentinel, and ordinary/edge z values; asserts the sentinel omits Z while homeRange bounds write it. Server.Tests 717/717, UOContent.Tests 487/487.
This commit is contained in:
Kamron Batman 2026-06-25 23:42:49 -07:00 committed by GitHub
parent d8a64f3316
commit a28a32f46d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 47 additions and 1 deletions

View file

@ -0,0 +1,46 @@
using System.Text.Json;
using Server.Json;
using Xunit;
namespace Server.Tests.Json;
public class Rectangle3DConverterTests
{
private static Rectangle3D RoundTrip(Rectangle3D value)
{
var json = JsonSerializer.Serialize(value, JsonConfig.DefaultOptions);
return JsonSerializer.Deserialize<Rectangle3D>(json, JsonConfig.DefaultOptions);
}
[Theory]
// homeRange-style full vertical range (z1=-128, depth 256 -> z2=128): must survive losslessly.
[InlineData(100, 100, -128, 11, 11, 256)]
// The omitted-z sentinel itself (z1=-128, z2=127 -> depth 255).
[InlineData(100, 100, -128, 11, 11, 255)]
// Ordinary bounds with a meaningful z.
[InlineData(100, 100, 0, 11, 11, 5)]
[InlineData(100, 100, 5, 11, 11, 122)] // z2 == 127 but z1 != -128
[InlineData(100, 100, -128, 11, 11, 200)] // z1 == -128 but z2 != 127
public void RoundTrips(int x, int y, int z, int w, int h, int d)
{
var rect = new Rectangle3D(x, y, z, w, h, d);
Assert.Equal(rect, RoundTrip(rect));
}
[Fact]
public void FullRangeSentinel_OmitsZ()
{
// z1=-128, z2=127 is the only case z is omitted.
var json = JsonSerializer.Serialize(new Rectangle3D(0, 0, -128, 10, 10, 255), JsonConfig.DefaultOptions);
Assert.DoesNotContain("z1", json);
Assert.DoesNotContain("z2", json);
}
[Fact]
public void HomeRangeBounds_WritesZ()
{
var json = JsonSerializer.Serialize(new Rectangle3D(0, 0, -128, 10, 10, 256), JsonConfig.DefaultOptions);
Assert.Contains("\"z1\":", json);
Assert.Contains("\"z2\":", json);
}
}

View file

@ -175,7 +175,7 @@ public class Rectangle3DConverter : JsonConverter<Rectangle3D>
public override void Write(Utf8JsonWriter writer, Rectangle3D value, JsonSerializerOptions options)
{
var writeZ = value.Start.Z is > sbyte.MinValue and < sbyte.MaxValue || value.End.Z is > sbyte.MinValue and < sbyte.MaxValue;
var writeZ = value.Start.Z != sbyte.MinValue || value.End.Z != sbyte.MaxValue;
writer.WriteStartObject();
writer.WritePropertyName("x1");