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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,66 @@
# AD-55 resolved by byte-decode — the Sledding flatness threshold is cos(10°), and our constant is a unit slip
**Date:** 2026-08-07 (overnight). **Method:** `reference_pe_byte_decode` — raw
bytes from the PDB-paired v11.4186 binary (`check_exe_pdb.py` → MATCH,
CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), not the decomp text.
## The question AD-55 filed
`PhysicsBody.calc_friction`'s Sledding near-flat branch compares
`GroundNormal.Z > 0.99999536f` (≈0.175° from flat). The raw decomp of
`CPhysicsObj::calc_friction` @0x0050ee70 instead shows
`__fcos(0.17453292519943295)` — cos(10°) ≈ 0.984808 — compared against
`contact_plane.N.z`. One of the two had to be a decode artifact.
## The bytes @0x0050ef53 (verbatim from the binary)
```
d9 86 38 01 00 00 fld dword [esi+0x138] ; contact_plane.N.z
dd 05 28 6b 7c 00 fld qword [0x007c6b28] ; the constant
d9 ff fcos
de d9 fcompp ; cos(const) vs N.z
df e0 fnstsw ax
f6 c4 41 test ah, 0x41
7a 0a jp +0x0a ; skip the friction load
8b 86 bc 00 00 00 mov eax, [esi+0xbc] ; this->friction
89 44 24 04 mov [esp+4], eax
d9 44 24 04 fld dword [esp+4]
```
`qword [0x007c6b28]` = **0.17453292519943295 = π/18 exactly** (verified by
direct read at the mapped file offset). The binary genuinely executes `FCOS`
at runtime — the compiler did not fold it — so the threshold retail compares
against `N.z` is **cos(π/18 rad) = cos(10°) = 0.984807753...**.
## The verdict
- **The decomp was RIGHT. Our port is wrong.** `0.99999536f` is
`cos(0.17453292519943295°)` — the *radian* literal read as *degrees* and
run through a degree→radian cosine. A one-character-class unit slip that
survived because nothing gates slope feel numerically.
- **Felt consequence:** the branch means "on ground flatter than the
threshold, use the object's own friction; on steeper ground (while slow —
the `arg3 < 6.25` speed² gate at 0x0050ef46 guards this), keep the 0.2
sliding friction." With our constant, "flat" requires < 0.175° real
terrain triangles essentially never qualify, so the object-friction arm of
Sledding is unreachable in practice and slow movers keep sliding friction
on gentle slopes retail treats as flat. Ice-feel in exactly the S4/S5
slope-feel family.
- **Fix shape (S5):** replace the constant with retail's semantics. Either
the folded `0.98480775f` with a comment carrying this evidence, or the
exact `MathF.Cos(MathF.PI / 18f)` computed once — prefer the folded
constant + comment, matching how AP-7's 0.25f landed. Polarity must be
ported from the `test ah,0x41; jp` idiom above, not assumed: the friction
load is SKIPPED when the jump is taken (cos(10°) > N.z, i.e. steeper than
10°, or unordered), and taken when N.z ≥ cos(10°). Verify our branch's
existing polarity against this before changing only the constant.
- The speed² gates in the same function — 1.5625 (= 1.25²) at 0x0050ef24 and
6.25 (= 2.5²) at 0x0050ef46 — matched our port already at AP-7 and are
untouched.
## Bookkeeping owed at the fix
Retire AD-55 (the row's open question is now answered against our constant);
conformance test pinning `0.98480775f` + the ported polarity; S4/S5's
slope-feel session covers the felt change. Until the fix lands, the register
row stands corrected by this note.

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;
}
}