champions-of-luneraria/Assets/Luneraria/Tests/Core/Diagnostics/FloatProbe.cs
Martin Stickelbroeck 44d3bcdee9 U1.5 Unity Core Integration (1/2): Unity-Projekt, Test Runner, Diagnose
- Projekt mit Unity 6.3 LTS (6000.3.20f1, Personal) geöffnet: ProjectSettings,
  .meta-Dateien, URP-Global-Settings, packages-lock.json.
- Manifest auf die benötigten Pakete zurückgeführt; Unity hatte beim ersten
  Öffnen Vorlagenpakete ergänzt (Purchasing, Analytics, Collab, XR, 2D, Navigation).
- Test Runner: Die U1-Suiten laufen unverändert als EditMode-Tests; mit
  LUNE_TEST_REPORT schreiben .NET und Unity dasselbe JSON-Protokoll (SuiteReport).
- Golden-Vergleich zusätzlich je Fachbereich (Gold, HP, Positionen, Türme,
  Ancients, Wellen, Helden, Boss, Gewinner, ...). Nur Bericht; Testwerte und
  Prüfungsanzahl unverändert.
- Diagnose: FloatProbe (Kern-Arithmetik gegen Godot-Bits und Auswertungsart der
  Laufzeit), DivergenceTrace und tools/port/trace_diff.py (erster abweichender
  Schritt), CoreBenchmark für .NET und Editor, CoreBatch als -executeMethod-Einstieg.
- .NET-Harness baut nach tools/out/, weil Unity Temp/ beim Beenden löscht.
- .gitignore ignoriert nur noch die csproj/sln im Projektwurzelverzeichnis;
  die Projektdateien des .NET-Harness fehlten deshalb in U1 und kommen jetzt dazu.

Stand dieses Commits im Unity-Editor (Mono): 4 von 5 Suiten grün; golden
152/157, weil Mono float32-Zwischenwerte doppelt genau rechnet (Teil 2/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:19:08 +02:00

213 lines
9.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Float diagnosis for a runtime (docs/port/U1.5-UNITY.md): evaluates the core's own Vec2 and
/// GdMath operations on the inputs of tools/godot-golden/float_probe.gd and compares the raw
/// bits with Godot's results (Golden/float_probe.json). A second part classifies how the
/// runtime evaluates float32 expressions: strictly per operation, with double-precision
/// intermediates (allowed by ECMA-335 I.12.1.3) or with fused multiply-add.
/// </summary>
public static class FloatProbe
{
public sealed class Result
{
public int Rows;
public readonly OrderedMap<string, int> Identical = new OrderedMap<string, int>();
public readonly OrderedMap<string, double> WorstUlps = new OrderedMap<string, double>();
public readonly OrderedMap<string, int> Evaluation = new OrderedMap<string, int>();
public int EvaluationSamples;
public int Mismatches
{
get
{
int total = 0;
foreach (KeyValuePair<string, int> pair in Identical)
total += Rows - pair.Value;
return total;
}
}
public string ToJson(string runtime, string label)
{
var text = new StringBuilder();
text.Append("{\n");
text.AppendFormat(" \"runtime\": \"{0}\",\n \"label\": \"{1}\",\n \"rows\": {2},\n", runtime, label, Rows);
text.Append(" \"identical_to_godot\": {");
bool first = true;
foreach (KeyValuePair<string, int> pair in Identical)
{
text.AppendFormat(CultureInfo.InvariantCulture, "{0}\n \"{1}\": {2}", first ? "" : ",", pair.Key, pair.Value);
first = false;
}
text.Append("\n },\n \"worst_ulps\": {");
first = true;
foreach (KeyValuePair<string, double> pair in WorstUlps)
{
text.AppendFormat(CultureInfo.InvariantCulture, "{0}\n \"{1}\": {2}", first ? "" : ",", pair.Key, pair.Value);
first = false;
}
text.AppendFormat("\n }},\n \"evaluation_samples\": {0},\n \"evaluation_matches\": {{", EvaluationSamples);
first = true;
foreach (KeyValuePair<string, int> pair in Evaluation)
{
text.AppendFormat(CultureInfo.InvariantCulture, "{0}\n \"{1}\": {2}", first ? "" : ",", pair.Key, pair.Value);
first = false;
}
text.Append("\n }\n}\n");
return text.ToString();
}
}
public static Result Run(string goldenDirectory)
{
var result = new Result();
var probe = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(goldenDirectory, "float_probe.json"))));
foreach (object item in MiniJson.Arr(probe["rows"]))
{
List<object> row = MiniJson.Arr(item);
float[] f = Floats(MiniJson.Str(row[0]));
double[] d = Doubles(MiniJson.Str(row[1]));
var a = new Vec2(f[0], f[1]);
var b = new Vec2(f[2], f[3]);
float w = f[4];
float s = f[5];
result.Rows++;
Compare(result, "length", a.Length(), f[6]);
Compare(result, "length_squared", a.LengthSquared(), f[7]);
Compare(result, "distance_to", a.DistanceTo(b), f[8]);
Compare(result, "distance_squared_to", a.DistanceSquaredTo(b), f[9]);
Compare(result, "dot", a.Dot(b), f[10]);
Vec2 n = a.Normalized();
Compare(result, "normalized", n.X, f[11], n.Y, f[12]);
Vec2 l = a.Lerp(b, w);
Compare(result, "lerp", l.X, f[13], l.Y, f[14]);
Vec2 c = a.LimitLength(0.9f);
Compare(result, "limit_length", c.X, f[15], c.Y, f[16]);
Compare(result, "multiply", (a * s).X, f[17]);
Compare(result, "divide", (a / s).Y, f[18]);
Compare(result, "sub_normalized", (a - b).Normalized().X, f[19]);
double angle = GdMath.Atan2(a.X, a.Y);
if (!result.Identical.ContainsKey("atan2"))
result.Identical["atan2"] = 0;
if (BitConverter.DoubleToInt64Bits(angle) == BitConverter.DoubleToInt64Bits(d[0]))
result.Identical["atan2"] += 1;
Classify(result, f);
}
return result;
}
static void Compare(Result result, string name, float actual, float expected)
{
Compare(result, name, actual, expected, 0f, 0f);
}
static void Compare(Result result, string name, float actualX, float expectedX, float actualY, float expectedY)
{
if (!result.Identical.ContainsKey(name))
{
result.Identical[name] = 0;
result.WorstUlps[name] = 0.0;
}
double ulps = Math.Max(Ulps(actualX, expectedX), Ulps(actualY, expectedY));
if (ulps == 0.0)
result.Identical[name] += 1;
if (ulps > result.WorstUlps[name])
result.WorstUlps[name] = ulps;
}
/// <summary>Distance in units of the last place (float32).</summary>
public static double Ulps(float a, float b)
{
if (BitConverter.ToInt32(BitConverter.GetBytes(a), 0) == BitConverter.ToInt32(BitConverter.GetBytes(b), 0))
return 0.0;
if (float.IsNaN(a) || float.IsNaN(b))
return double.PositiveInfinity;
long ia = Ordered(a);
long ib = Ordered(b);
return Math.Abs(ia - ib);
}
static long Ordered(float value)
{
int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
return bits < 0 ? (long)int.MinValue - bits : bits;
}
/// <summary>
/// How does this runtime evaluate `x*x + y*y` and `a + (b - a) * w` with float32 operands?
/// The candidates are computed with explicit conversions, which ECMA-335 guarantees to
/// narrow: per-operation float32, double intermediates rounded once, fused multiply-add.
/// </summary>
static void Classify(Result result, float[] f)
{
float x = f[0];
float y = f[1];
float a = f[0];
float b = f[2];
float w = f[4];
float natural = SumOfSquares(x, y);
float strict = (float)((float)(x * x) + (float)(y * y));
float wide = (float)((double)x * x + (double)y * y);
float fused = (float)((double)x * x + (double)(float)(y * y));
float naturalLerp = LerpNatural(a, b, w);
float strictLerp = (float)(a + (float)((float)(b - a) * w));
float wideLerp = (float)(a + ((double)b - a) * w);
result.EvaluationSamples++;
Count(result, "sum_of_squares_strict_float32", natural == strict);
Count(result, "sum_of_squares_double_intermediate", natural == wide);
Count(result, "sum_of_squares_fused", natural == fused);
Count(result, "sum_of_squares_candidates_differ", strict != wide);
Count(result, "lerp_strict_float32", naturalLerp == strictLerp);
Count(result, "lerp_double_intermediate", naturalLerp == wideLerp);
Count(result, "lerp_candidates_differ", strictLerp != wideLerp);
}
static void Count(Result result, string key, bool hit)
{
if (!result.Evaluation.ContainsKey(key))
result.Evaluation[key] = 0;
if (hit)
result.Evaluation[key] += 1;
}
// Kept out of line so the expressions are compiled the way the core's own code is.
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
static float SumOfSquares(float x, float y) { return x * x + y * y; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
static float LerpNatural(float a, float b, float w) { return a + (b - a) * w; }
static float[] Floats(string hex)
{
byte[] bytes = Bytes(hex);
var values = new float[bytes.Length / 4];
for (int i = 0; i < values.Length; i++)
values[i] = BitConverter.ToSingle(bytes, i * 4);
return values;
}
static double[] Doubles(string hex)
{
byte[] bytes = Bytes(hex);
var values = new double[bytes.Length / 8];
for (int i = 0; i < values.Length; i++)
values[i] = BitConverter.ToDouble(bytes, i * 8);
return values;
}
static byte[] Bytes(string hex)
{
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
bytes[i] = byte.Parse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
return bytes;
}
}
}