test(physics): AP-157 measured — CylHeight half retired, sorting-sphere half proven collision-unreachable; AD-55 byte-decoded
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Campaign S S1A, both outcomes the measure-first rule exists for.

AP-157's CylHeight half is RETIRED as a non-divergence: retail's own
cylsphere overload (CObjCell::find_cell_list @0x0052b9f0) copies
localtoglobal(low_pt) + radius per cylsphere, capped at 10, and never
reads height — retail collapses a cylsphere to a base-point sphere
exactly as acdream does.

The sorting-sphere half measured REAL against retail's registration set
— 1,812 of 3,343 evaluated Setups (54%) fail containment at 1 mm, worst
shortfall 18.135 m — and then PROVEN collision-unreachable: for this
branch the flood spheres and the collision-test geometry are the same
per-part Sphere list, so every omitted cell is one the entity's test
geometry cannot reach, and retail's wider sorting-sphere registrations
are narrow-phase rejects on retail too. Fix deferred to the next
bake-schema revision rather than performing Slice I3 surgery for zero
behavioural delta. The measurement test stays in the tree as the
permanent record (population cross-checked against the dispatch test's
independently-committed constants: 3,506 = 3,605 - 99).

AD-55 is byte-decoded and RESOLVED against our constant: the binary
loads qword [0x007c6b28] = pi/18 exactly and executes FCOS — retail's
Sledding flatness threshold is cos(10 deg) = 0.984808. Our 0.99999536f
is cos(0.17453 DEGREES): the radian literal misread as degrees, which
makes the object-friction arm unreachable on real terrain (nothing is
flatter than 0.175 deg). Evidence note carries the full instruction
listing and the polarity of the test ah,0x41 / jp idiom; the one-line
fix + conformance test is S5, queued behind the running implementation
slice for build-slot reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 00:57:36 +02:00
parent 04b794ad7c
commit 52aea775b9
3 changed files with 352 additions and 1 deletions

View file

@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// AP-157 (sorting-sphere half) MEASUREMENT ONLY. Retail's third
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230 branch — an object with
/// NO physics-BSP part and NO CylSpheres — floods cell membership from ONE
/// authored whole-object sphere: <c>CPartArray::GetSortingSphere</c>
/// @0x00518b00 returns <c>CSetup::sorting_sphere</c>, and
/// <c>CObjCell::find_cell_list</c> @0x0052b990 (the sorting-sphere overload,
/// which pushes a literal 1 at 0x0052b9d6) takes that single sphere.
/// <see cref="ShadowObjectRegistry"/>'s <c>BuildFloodSpheres</c> instead
/// floods from EVERY per-part <c>Setup.Spheres</c> shape it emits for that
/// same object (a different DAT field, a different cardinality — see the
/// AP-157 note inline at <c>ShadowObjectRegistry.cs</c> around
/// <c>BuildFloodSpheres</c>). This sweep asks: over the installed
/// <c>client_portal.dat</c>, is the per-Sphere flood ever UNDER-inclusive
/// relative to the authored sorting sphere?
///
/// <para>
/// The CylHeight half of AP-157 is a SEPARATE, ALREADY-SETTLED
/// non-divergence (retail's cylsphere overload @0x0052b9f0 never reads
/// height) and is NOT covered here.
/// </para>
///
/// <para>
/// No re-derivation of <c>BuildFloodSpheres</c>' private branch logic: the
/// classifier is <see cref="ShadowShapeBuilder.FromSetup"/>'s own OUTPUT
/// (production), and the flood-sphere geometry is read back from
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> +
/// <see cref="ShadowObjectRegistry.AllEntriesForDebug"/> — the same
/// test-visible entry <c>ShadowObjectRegistryMultiPartTests</c> uses. This
/// is valid for exactly the population this test selects (zero BSP shapes,
/// zero Cylinder shapes, at least one Sphere shape): a primitive shape's
/// <c>BoundsCenter</c> is always <c>Vector3.Zero</c>
/// (<see cref="ShadowShape.Sphere"/>), so <c>BuildFloodSpheres</c>'
/// composition <c>partWorldPos + Transform(BoundsCenter, partWorldRot)</c>
/// reduces to exactly <c>partWorldPos</c> — algebraically identical to the
/// <c>ShadowEntry.Position</c> that <c>RegisterMultiPart</c> writes for
/// every shape it registers. Registering at world origin with identity
/// rotation makes that <c>partWorldPos</c> equal to the shape's raw
/// <c>LocalPosition</c>, i.e. the Setup's own authored frame — the same
/// frame <c>Setup.SortingSphere</c> is authored in. And because this
/// population by construction carries zero Cylinder shapes,
/// <c>BuildFloodSpheres</c>' internal dispatch (cylsphere branch vs.
/// sphere-shape branch) always resolves to the sphere-shape branch with no
/// cap — every emitted Sphere shape becomes a flood sphere, so
/// <c>AllEntriesForDebug()</c>'s deduplicated per-shape rows are exactly
/// the flood-sphere list, not a subset or approximation of it.
/// </para>
/// </summary>
public sealed class Ap157SortingSphereFloodMeasurementTests
{
/// <summary>Arbitrary non-zero landblock id — only its prefix matters for
/// seeding the outdoor flood; the entity is placed at the landblock's own
/// origin so cell (0,0) always resolves.</summary>
private const uint LandblockId = 0xA9B40000u;
private const int SurfaceSampleDirections = 256; // >= 200 required by the task
[Fact]
public void InstalledSetups_ThirdBranchSortingSphereFloodCoverage_Measured()
{
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null)
{
Console.WriteLine("SKIP: installed retail DAT directory is unavailable.");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
// Production physics-BSP predicate — same as
// InstalledSetupBspPrimitiveDispatchTests / FlatCollisionAssetBuilder.cs:377-380.
var physicsBspCache = new Dictionary<uint, bool>();
bool HasPhysicsBsp(uint gfxObjId)
{
if (physicsBspCache.TryGetValue(gfxObjId, out bool cached))
return cached;
bool result =
dats.Portal.TryGet<GfxObj>(gfxObjId, out GfxObj? gfx)
&& gfx is not null
&& gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)
&& gfx.PhysicsBSP?.Root is not null
&& gfx.VertexArray is not null;
physicsBspCache[gfxObjId] = result;
return result;
}
IReadOnlyList<Vector3> sampleDirections = BuildFibonacciSphereDirections(SurfaceSampleDirections);
int totalSetups = 0;
int thirdBranchPopulation = 0;
int zeroRadiusSortingSphereCount = 0;
int emptyFloodCount = 0;
int failuresAt1mm = 0;
int failuresAt1cm = 0;
float worstShortfallMetres = 0f;
uint worstShortfallSetupId = 0u;
float maxOvershootMetres = 0f;
uint maxOvershootSetupId = 0u;
// Top-3 worst shortfalls for the report (task asks for the worst three).
var worstThree = new List<(uint SetupId, float Shortfall)>();
foreach (uint id in dats.GetAllIdsOfType<Setup>())
{
if (!dats.Portal.TryGet<Setup>(id, out Setup? setup) || setup is null)
continue;
totalSetups++;
// The classifier: production FromSetup output, not raw Setup fields.
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(setup, 1f, HasPhysicsBsp);
int bspCount = 0, cylCount = 0, sphCount = 0;
foreach (ShadowShape shape in shapes)
{
switch (shape.CollisionType)
{
case ShadowCollisionType.BSP: bspCount++; break;
case ShadowCollisionType.Cylinder: cylCount++; break;
case ShadowCollisionType.Sphere: sphCount++; break;
}
}
if (bspCount != 0 || cylCount != 0 || sphCount == 0)
continue; // not the third branch
thirdBranchPopulation++;
// Register through the PRODUCTION path, at world origin / identity
// rotation / scale 1, then read the flood spheres back through the
// same test-visible entry the MultiPart tests use.
var reg = new ShadowObjectRegistry();
const uint ownerId = 0xCAFEu;
reg.RegisterMultiPart(
ownerId, Vector3.Zero, Quaternion.Identity,
shapes, 0u, EntityCollisionFlags.None,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: LandblockId);
var flood = new List<(Vector3 Centre, float Radius)>();
foreach (ShadowEntry entry in reg.AllEntriesForDebug())
{
if (entry.EntityId != ownerId) continue;
flood.Add((entry.Position, entry.Radius));
}
if (flood.Count == 0)
{
emptyFloodCount++;
continue;
}
Vector3 sortingCentre = setup.SortingSphere.Origin;
float sortingRadius = setup.SortingSphere.Radius;
// Reverse direction (overshoot) — meaningful regardless of the
// sorting sphere's radius, including zero.
foreach ((Vector3 fc, float fr) in flood)
{
float overshoot = (Vector3.Distance(fc, sortingCentre) + fr) - sortingRadius;
if (overshoot > maxOvershootMetres)
{
maxOvershootMetres = overshoot;
maxOvershootSetupId = id;
}
}
if (sortingRadius <= 0f)
{
// Retail floods from a ZERO sphere here — acdream's per-Sphere
// flood is strictly MORE inclusive by construction. Not a
// failure; tallied separately, no containment sampling run.
zeroRadiusSortingSphereCount++;
continue;
}
float worst = EvaluateWorstShortfall(sortingCentre, sortingRadius, flood, sampleDirections);
bool fail1mm = worst > 0.001f;
bool fail1cm = worst > 0.01f;
if (fail1mm) failuresAt1mm++;
if (fail1cm) failuresAt1cm++;
if (fail1mm)
{
worstThree.Add((id, worst));
worstThree.Sort((a, b) => b.Shortfall.CompareTo(a.Shortfall));
if (worstThree.Count > 3) worstThree.RemoveRange(3, worstThree.Count - 3);
}
if (worst > worstShortfallMetres)
{
worstShortfallMetres = worst;
worstShortfallSetupId = id;
}
}
Console.WriteLine("===== AP-157 sorting-sphere flood measurement =====");
Console.WriteLine($"Total installed Setups: {totalSetups}");
Console.WriteLine($"Third-branch population (0 BSP, 0 Cyl, >=1 Sphere shape): {thirdBranchPopulation}");
Console.WriteLine($" of which SortingSphere.Radius == 0: {zeroRadiusSortingSphereCount} (NOT failures — retail floods from a zero sphere there too)");
Console.WriteLine($" of which flood sphere list was empty: {emptyFloodCount} (structural anomaly, excluded from containment tallies)");
int evaluated = thirdBranchPopulation - zeroRadiusSortingSphereCount - emptyFloodCount;
Console.WriteLine($"Evaluated for containment (radius > 0, non-empty flood): {evaluated}");
Console.WriteLine($"Containment failures at 1 mm tolerance: {failuresAt1mm}");
Console.WriteLine($"Containment failures at 1 cm tolerance: {failuresAt1cm}");
Console.WriteLine(
$"Worst shortfall: {worstShortfallMetres.ToString("F4", CultureInfo.InvariantCulture)} m "
+ $"on Setup 0x{worstShortfallSetupId:X8}");
Console.WriteLine("Worst three (setup id, shortfall metres):");
foreach ((uint setupId, float shortfall) in worstThree)
{
Console.WriteLine(
$" 0x{setupId:X8}: {shortfall.ToString("F4", CultureInfo.InvariantCulture)} m");
}
Console.WriteLine(
$"Max overshoot (flood extends beyond sorting sphere): "
+ $"{maxOvershootMetres.ToString("F4", CultureInfo.InvariantCulture)} m "
+ $"on Setup 0x{maxOvershootSetupId:X8}");
Console.WriteLine("====================================================");
// Structural sanity only — this is a measurement, not a gate.
Assert.True(totalSetups > 0, "Expected the installed DAT to enumerate at least one Setup.");
Assert.True(thirdBranchPopulation > 0, "Expected at least one third-branch Setup in the installed DAT.");
}
/// <summary>
/// Max over every sampled point on (and at the centre of) the sorting
/// sphere of "distance to the nearest flood sphere's surface" — negative
/// or zero means the point sits inside some flood sphere; positive is an
/// uncovered shortfall in metres.
/// </summary>
private static float EvaluateWorstShortfall(
Vector3 sortingCentre,
float sortingRadius,
IReadOnlyList<(Vector3 Centre, float Radius)> flood,
IReadOnlyList<Vector3> sampleDirections)
{
float worst = float.NegativeInfinity;
void Probe(Vector3 point)
{
float best = float.MaxValue;
foreach ((Vector3 fc, float fr) in flood)
{
float need = (point - fc).Length() - fr;
if (need < best) best = need;
}
if (best > worst) worst = best;
}
Probe(sortingCentre);
foreach (Vector3 dir in sampleDirections)
Probe(sortingCentre + dir * sortingRadius);
return worst;
}
/// <summary>Fibonacci-sphere unit directions — a simple, deterministic,
/// near-uniform surface sample set.</summary>
private static IReadOnlyList<Vector3> BuildFibonacciSphereDirections(int count)
{
var dirs = new List<Vector3>(count);
double goldenAngle = Math.PI * (3.0 - Math.Sqrt(5.0));
for (int i = 0; i < count; i++)
{
double y = 1.0 - (i / (double)(count - 1)) * 2.0; // 1 .. -1
double radiusAtY = Math.Sqrt(Math.Max(0.0, 1.0 - y * y));
double theta = goldenAngle * i;
double x = Math.Cos(theta) * radiusAtY;
double z = Math.Sin(theta) * radiusAtY;
dirs.Add(new Vector3((float)x, (float)y, (float)z));
}
return dirs;
}
}