From f192701c575e7580bad0b711eab4b58d25de4322 Mon Sep 17 00:00:00 2001 From: Veeno Date: Wed, 3 Jun 2026 13:57:49 +0200 Subject: [PATCH 1/2] Implement a proper practical unit test for RectUtil and make it actually work right. --- RLBotCS/ManagerTools/RectUtil.cs | 134 ++++++---------------- RLBotCS/ManagerTools/Rendering.cs | 2 +- RLBotCSTests/ManagerTools/RectUtilTest.cs | 47 ++++++-- 3 files changed, 75 insertions(+), 108 deletions(-) diff --git a/RLBotCS/ManagerTools/RectUtil.cs b/RLBotCS/ManagerTools/RectUtil.cs index df3c26b..e8ccdc0 100644 --- a/RLBotCS/ManagerTools/RectUtil.cs +++ b/RLBotCS/ManagerTools/RectUtil.cs @@ -1,125 +1,63 @@ -using System.Collections.Immutable; +using System.Numerics; namespace RLBotCS.ManagerTools; public static class RectUtil { + private static readonly int LeadingZerosForUShort = BitOperations.LeadingZeroCount( + (uint)UInt16.MaxValue + ); + /// - /// The maximum number of subdivisions of the [0,1] interval to store. - /// The maximum possible resulting number of entries is ⌈MaxSubdivisions/2⌉, - /// but only those whose sum of the numerator and denominator does - /// not excede Rendering.RectangleStringMaxLength are included, - /// so ideally this should be a highly composite number.
- /// For 55440, there are 17635 entries, corresponding to 137.77 kiB of memory. + /// Greatest common divisor by Euclidean algorithm.
+ /// An optimized implementation based on https://stackoverflow.com/a/41766138. ///
- private const ushort MaxSubdivisions = 55440; - - private const float HalfPrecisionRangeHigh = 4096f; - private const float HalfPrecisionRangeLow = 1.0f / HalfPrecisionRangeHigh; - - private static readonly ImmutableArray ratios; - private static readonly ImmutableArray<(ushort, ushort)> rects; - - static RectUtil() + private static uint Gcd(uint a, uint b) { - static int Gcd(int a, int b) - { - // Greatest common divisor by Euclidean algorithm https://stackoverflow.com/a/41766138 - while (a != 0 && b != 0) - { - if (a > b) - a %= b; - else - b %= a; - } - - return a | b; - } - - SortedDictionary dictionary = []; - float fMaxSubdivisions = MaxSubdivisions; - for (ushort i = MaxSubdivisions / 2 + MaxSubdivisions % 2; i <= MaxSubdivisions; ++i) + if (a >= b) + a %= b; + while (a != 0) { - ushort gcd = (ushort)Gcd(i, MaxSubdivisions); - ushort num = (ushort)(i / gcd); - ushort den = (ushort)(MaxSubdivisions / gcd); - if (num + den <= Rendering.RectangleStringMaxLength) - dictionary.Add(i / fMaxSubdivisions, (num, den)); + b %= a; + if (b == 0) + return a; + a %= b; } - - ratios = [.. dictionary.Keys]; - rects = [.. dictionary.Values]; + return b; } - private static float GeoMean(float a, float b) - { - if ( - a >= HalfPrecisionRangeHigh - || b >= HalfPrecisionRangeHigh - || a <= HalfPrecisionRangeLow - || b <= HalfPrecisionRangeLow - ) - return MathF.Sqrt(a) * MathF.Sqrt(b); - return MathF.Sqrt(a * b); - } - - private static (ushort, ushort) FindImpl(float value) - { - int higherIdx = ratios.BinarySearch(value); - - if (higherIdx >= 0) - return rects[higherIdx]; - - higherIdx = ~higherIdx; - - // No need to handle this because value >= 0.5 == ratios.First() - //if (higherIdx == 0) - // return rects.First(); - - // No need to handle this because value <= 1.0 == ratios.Last() - //if (higherIdx == ratios.Length) - // return rects.Last(); - - int lowerIdx = higherIdx - 1; - return rects[value * 2 < ratios[lowerIdx] + ratios[higherIdx] ? lowerIdx : higherIdx]; - } - - private static (ushort, ushort) Find(float value) - { - if (value >= 0.5) - return FindImpl(value); - - (ushort num, ushort den) = FindImpl(1f - value); - return ((ushort)(den - num), den); - } - - private static (ushort cols, ushort rows) Find(float width, float height) + /// + /// Discards the same number of least significant bits from a and b + /// if either is too large to fit into a ushort. + /// + private static (ushort, ushort, int) SafeCast(uint a, uint b) { - if (width <= height) - return Find(width / height); - - (ushort rows, ushort cols) = Find(height / width); - return (cols, rows); + if (a <= UInt16.MaxValue && b <= UInt16.MaxValue) + return ((ushort)a, (ushort)b, 0); + + int shift = Int32.Max( + LeadingZerosForUShort - BitOperations.LeadingZeroCount(a), + LeadingZerosForUShort - BitOperations.LeadingZeroCount(b) + ); + return ((ushort)(a >> shift), (ushort)(b >> shift), shift); } /// - /// Approximates the rectangle width×height with cols×rows + /// Represents the rectangle width×height with cols×rows /// rectangles with dimensions elementWidth×elementHeight scaled by scale. /// - public static (ushort cols, ushort rows, float scale) ApproximateRect( + public static (ushort cols, ushort rows, float scale) RectSolve( uint width, uint height, uint elementWidth, uint elementHeight ) { - float elementsInWidth = (float)width / elementWidth; - float elementsInHeight = (float)height / elementHeight; - (ushort cols, ushort rows) = Find(elementsInWidth, elementsInHeight); + uint wh = width * elementHeight; + uint hw = height * elementWidth; + uint gcd = Gcd(wh, hw); + (ushort cols, ushort rows, int shift) = SafeCast(wh / gcd, hw / gcd); - // Ideal horizontal and vertical scale are - // ((float)width / cols) / elementWidth == ((float)width / elementWidth) / cols == elementsInWidth / cols - // ((float)height / rows) / elementHeight == ((float)height / elementHeight) / rows == elementsInHeight / rows - return (cols, rows, GeoMean(elementsInWidth / cols, elementsInHeight / rows)); + return (cols, rows, ((float)(gcd >> shift)) / (elementWidth * elementHeight)); } } diff --git a/RLBotCS/ManagerTools/Rendering.cs b/RLBotCS/ManagerTools/Rendering.cs index a2514bd..cd0029c 100644 --- a/RLBotCS/ManagerTools/Rendering.cs +++ b/RLBotCS/ManagerTools/Rendering.cs @@ -137,7 +137,7 @@ private ushort SendRect3D(Rect3DT rect3Dt, GameState gameState) /// The rectangle string and the font scaling private (string, float) MakeFakeRectangleString(uint width, uint height) { - (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect( + (ushort cols, ushort rows, float scale) = RectUtil.RectSolve( width, height, FontWidthPixels, diff --git a/RLBotCSTests/ManagerTools/RectUtilTest.cs b/RLBotCSTests/ManagerTools/RectUtilTest.cs index fe93db8..b1adb25 100644 --- a/RLBotCSTests/ManagerTools/RectUtilTest.cs +++ b/RLBotCSTests/ManagerTools/RectUtilTest.cs @@ -1,4 +1,5 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; using RLBotCS.ManagerTools; namespace RLBotCSTests.ManagerTools; @@ -6,16 +7,21 @@ namespace RLBotCSTests.ManagerTools; [TestClass] public class RectUtilTest { - const uint TestUpTo = 64; + private static (float, float) RoundingBounds(uint n) + { + return (MathF.BitIncrement(n - 0.5f), MathF.BitDecrement(n + 0.5f)); + } [TestMethod] - public void ApproximateRectTest() + public void ApproximateRectTest_Generic() { + const uint TestUpTo = 64; + for (uint i = 1; i <= TestUpTo; ++i) { for (uint j = 1; j <= TestUpTo; ++j) { - (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect(i, i, j, j); + (ushort cols, ushort rows, float scale) = RectUtil.RectSolve(i, i, j, j); Assert.AreEqual(1, cols); Assert.AreEqual(1, rows); // Slightly iffy, but it passes. @@ -25,22 +31,21 @@ public void ApproximateRectTest() for (uint i = 1; i <= TestUpTo; ++i) { - float iMin = i * 0.96f; - float iMax = i * 1.04f; + (float iMin, float iMax) = RoundingBounds(i); for (uint j = 1; j <= TestUpTo; ++j) { - float jMin = j * 0.96f; - float jMax = j * 1.04f; + (float jMin, float jMax) = RoundingBounds(j); for (uint k = 1; k <= TestUpTo; ++k) { for (uint l = 1; l <= TestUpTo; ++l) { - (ushort cols, ushort rows, float scale) = RectUtil.ApproximateRect( + (ushort cols, ushort rows, float scale) = RectUtil.RectSolve( i, j, k, l ); + Assert.IsLessThan(Rendering.RectangleStringMaxLength + 1, cols + rows); Assert.IsInRange(iMin, iMax, k * scale * cols); Assert.IsInRange(jMin, jMax, l * scale * rows); } @@ -48,4 +53,28 @@ public void ApproximateRectTest() } } } + + [TestMethod] + public void ApproximateRectTest_Practical() + { + const uint TestUpTo = 7680; + + for (uint i = 1; i <= TestUpTo; ++i) + { + (float iMin, float iMax) = RoundingBounds(i); + for (uint j = 1; j <= TestUpTo; ++j) + { + (float jMin, float jMax) = RoundingBounds(j); + (ushort cols, ushort rows, float scale) = RectUtil.RectSolve( + i, + j, + Rendering.FontWidthPixels, + Rendering.FontHeightPixels + ); + Assert.IsLessThan(Rendering.RectangleStringMaxLength + 1, cols + rows); + Assert.IsInRange(iMin, iMax, Rendering.FontWidthPixels * scale * cols); + Assert.IsInRange(jMin, jMax, Rendering.FontHeightPixels * scale * rows); + } + } + } } From 5a83c0eaca45c6353901d85625bb6eed8c00b368 Mon Sep 17 00:00:00 2001 From: Veeno Date: Wed, 3 Jun 2026 16:27:35 +0200 Subject: [PATCH 2/2] Fix test names, make optional scaling due to SafeCast work better. --- RLBotCS/ManagerTools/RectUtil.cs | 10 +++++----- RLBotCSTests/ManagerTools/RectUtilTest.cs | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/RLBotCS/ManagerTools/RectUtil.cs b/RLBotCS/ManagerTools/RectUtil.cs index e8ccdc0..e3eb597 100644 --- a/RLBotCS/ManagerTools/RectUtil.cs +++ b/RLBotCS/ManagerTools/RectUtil.cs @@ -30,16 +30,16 @@ private static uint Gcd(uint a, uint b) /// Discards the same number of least significant bits from a and b /// if either is too large to fit into a ushort. /// - private static (ushort, ushort, int) SafeCast(uint a, uint b) + private static (ushort, ushort, float) SafeCast(uint a, uint b) { if (a <= UInt16.MaxValue && b <= UInt16.MaxValue) - return ((ushort)a, (ushort)b, 0); + return ((ushort)a, (ushort)b, 1f); int shift = Int32.Max( LeadingZerosForUShort - BitOperations.LeadingZeroCount(a), LeadingZerosForUShort - BitOperations.LeadingZeroCount(b) ); - return ((ushort)(a >> shift), (ushort)(b >> shift), shift); + return ((ushort)(a >> shift), (ushort)(b >> shift), 1f / (1u << shift)); } /// @@ -56,8 +56,8 @@ uint elementHeight uint wh = width * elementHeight; uint hw = height * elementWidth; uint gcd = Gcd(wh, hw); - (ushort cols, ushort rows, int shift) = SafeCast(wh / gcd, hw / gcd); + (ushort cols, ushort rows, float reduction) = SafeCast(wh / gcd, hw / gcd); - return (cols, rows, ((float)(gcd >> shift)) / (elementWidth * elementHeight)); + return (cols, rows, (gcd * reduction) / (elementWidth * elementHeight)); } } diff --git a/RLBotCSTests/ManagerTools/RectUtilTest.cs b/RLBotCSTests/ManagerTools/RectUtilTest.cs index b1adb25..3656f66 100644 --- a/RLBotCSTests/ManagerTools/RectUtilTest.cs +++ b/RLBotCSTests/ManagerTools/RectUtilTest.cs @@ -13,7 +13,7 @@ private static (float, float) RoundingBounds(uint n) } [TestMethod] - public void ApproximateRectTest_Generic() + public void RectSolveTest_Generic() { const uint TestUpTo = 64; @@ -55,7 +55,7 @@ public void ApproximateRectTest_Generic() } [TestMethod] - public void ApproximateRectTest_Practical() + public void RectSolveTest_Practical() { const uint TestUpTo = 7680;