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
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:
parent
04b794ad7c
commit
52aea775b9
3 changed files with 352 additions and 1 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue