fix(physics): TS-46 - seed the sweep from the Setup's own sphere list

Campaign P Slice P3 item 1. Retail CPhysicsObj::transition (0x00512dc0)
seeds the collision sweep from CPartArray::GetSphere (the Setup's own
<=2-sphere list, each origin+radius scaled by m_scale) via
SPHEREPATH::init_sphere (0x0050c670) -- not from a symmetric two-scalar
(radius, height) capsule reconstruction. The human Setup 0x02000001's
authored spheres are (0,0,0.475) r=.48 and (0,0,1.350) r=.48; the old
reconstruction from (0.48, 1.835) produced (0,0,0.48) + (0,0,1.355), a
5 mm head-center offset the TS-46 register row documented as a residual.

Port:
- SpherePath.InitPath gains a sphere-list overload (ImmutableArray<
  FlatCollisionSphere>, scale) sharing a new InitPathCore with the
  existing (radius, height) overload, which is now the degenerate
  2-scalar case of the same code -- byte-for-byte unchanged, so every
  captured-fixture replay (CellarUpTrajectoryReplayTests,
  DoorBugTrajectoryReplayTests, CellarLipWedgeTests) keeps passing
  unmodified.
- PhysicsEngine.ResolveWithTransition gains optional sphereList/
  sphereScale parameters; empty/default preserves the legacy scalar
  path for every pre-existing caller.
- LiveEntityMotionRuntimeController.GetSetupMoverShape is a new sibling
  of GetSetupCylinder (left untouched) that resolves the Setup's own
  sphere list plus Setup-derived step-up/step-down
  (CPartArray::GetStepUpHeight/GetStepDownHeight, 0x005180d0/0x005180f0,
  x ObjScale, 0.4 m fallback matching the pre-existing literal).
- Threaded through PlayerMovementController (both resolve call sites,
  new SphereList property set by PlayerModeController.ApplyStepHeights
  and the Headless world projection), RuntimeRemotePhysicsUpdater
  (Tick + TickHidden), and RuntimeOrdinaryPhysicsUpdater.TryBegin.
  Remote/ordinary step heights are now Setup-derived instead of a
  hardcoded 0.4f literal. Projectile and camera-probe sweeps are
  untouched (already single-sphere-exact).
- PlayerModeController.ApplyStepHeights also now applies the x ObjScale
  multiply to the player's own step heights (previously only the
  remote/ordinary paths did), closing an adjacent gap the P3 research
  flagged.

Ts46SphereListConformanceTests proves the sphere-list overload sees the
exact dat spheres (not the reconstruction), that the scalar overload is
unchanged, and that ResolveWithTransition's sphereList parameter
actually drives the sweep (a decoy-scalar control pair using a
head-height obstacle sphere).

Register: TS-46 retired (both residuals it named are closed); header
count corrected to 40 active TS rows.

dotnet build + dotnet test (Core.Tests 3991/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 09:05:44 +02:00
parent 3dc10accb0
commit dae5b1ea68
21 changed files with 648 additions and 50 deletions

View file

@ -0,0 +1,276 @@
using System;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for Campaign P Slice P3's TS-46 port: retail
/// <c>CPhysicsObj::transition</c> (0x00512dc0) seeds the sweep from the
/// Setup's OWN sphere list via <c>SPHEREPATH::init_sphere</c> (0x0050c670),
/// not from a symmetric two-scalar (radius, height) capsule reconstruction.
/// The dat human Setup 0x02000001's authored spheres are <c>(0,0,0.475)
/// r=.48</c> and <c>(0,0,1.350) r=.48</c> (Height = 1.835); the pre-TS-46
/// reconstruction from (0.48, 1.835) produced a foot sphere at
/// <c>(0,0,0.48)</c> and a head sphere at <c>(0,0,1.355)</c> — a 5 mm
/// head-center offset that TS-46's own register row (retail-divergence-
/// register.md) documented as a residual. These tests prove the sphere-list
/// overload now sees the EXACT dat spheres, that the pre-existing scalar
/// overload is byte-for-byte unchanged (so captured-fixture replays that
/// use it stay green), and that <see cref="PhysicsEngine.ResolveWithTransition"/>
/// actually routes the sphere list into the sweep rather than silently
/// preferring the legacy scalars when both are supplied.
/// </summary>
public class Ts46SphereListConformanceTests
{
private readonly ITestOutputHelper _out;
public Ts46SphereListConformanceTests(ITestOutputHelper output) => _out = output;
private const uint TestLandblockId = 0xA9C50000u;
private const uint TestCellId = TestLandblockId | 0x0001u;
// Human Setup 0x02000001's verbatim authored sphere list.
private static readonly ImmutableArray<FlatCollisionSphere> HumanSetupSpheres =
ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f));
[Fact]
public void SphereListInitPath_MatchesDatSpheresExactly_NotTheReconstruction()
{
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: new Vector3(1f, 0f, 0f),
cellId: TestCellId,
spheres: HumanSetupSpheres,
scale: 1f);
Assert.Equal(2, sp.NumSphere);
// Foot sphere: dat-exact (0,0,0.475) r=.48 — NOT the reconstruction's
// (0,0,0.48) (the reconstruction and the dat happen to agree on the
// RADIUS-as-Z-origin coincidence for the foot sphere, but the true
// origin is 0.475, not 0.48 — assert the dat value, not the coincidence).
Assert.Equal(new Vector3(0f, 0f, 0.475f), sp.LocalSphere[0].Origin);
Assert.Equal(0.48f, sp.LocalSphere[0].Radius);
// Head sphere: dat-exact (0,0,1.350) — NOT the reconstruction's
// (0,0,1.355). This is the load-bearing assertion: TS-46's register
// row documents this exact 5 mm gap as the residual being retired.
Assert.Equal(new Vector3(0f, 0f, 1.350f), sp.LocalSphere[1].Origin);
Assert.Equal(0.48f, sp.LocalSphere[1].Radius);
Assert.NotEqual(1.355f, sp.LocalSphere[1].Origin.Z);
}
[Fact]
public void SphereListInitPath_AppliesScaleToOriginAndRadius()
{
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: Vector3.Zero,
cellId: TestCellId,
spheres: HumanSetupSpheres,
scale: 2f);
// Retail SPHEREPATH::init_sphere multiplies EACH sphere's origin AND
// radius by the object's own m_scale (wire ObjScale) — not just the
// radius.
Assert.Equal(new Vector3(0f, 0f, 0.95f), sp.LocalSphere[0].Origin);
Assert.Equal(0.96f, sp.LocalSphere[0].Radius);
Assert.Equal(new Vector3(0f, 0f, 2.70f), sp.LocalSphere[1].Origin);
Assert.Equal(0.96f, sp.LocalSphere[1].Radius);
}
[Fact]
public void SphereListInitPath_CapsAtTwoSpheres_MatchingRetailHardCap()
{
// SPHEREPATH::init_sphere (0x0050c670): num_sphere = min(count, 2).
var threeSpheres = ImmutableArray.Create(
new FlatCollisionSphere(new Vector3(0f, 0f, 0.475f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 1.350f), 0.48f),
new FlatCollisionSphere(new Vector3(0f, 0f, 2.0f), 0.20f));
var sp = new SpherePath();
sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, threeSpheres, scale: 1f);
Assert.Equal(2, sp.NumSphere);
Assert.Equal(1.350f, sp.LocalSphere[1].Origin.Z);
}
[Fact]
public void SphereListInitPath_EmptyList_FallsBackToDummySphere()
{
// Retail transition()'s numSphere==0 arm: a single dummy sphere,
// scale 1.0 — not a degenerate zero-radius sweep.
var sp = new SpherePath();
sp.InitPath(Vector3.Zero, Vector3.Zero, TestCellId, ImmutableArray<FlatCollisionSphere>.Empty);
Assert.Equal(1, sp.NumSphere);
Assert.Equal(PhysicsGlobals.DummySphereRadius, sp.LocalSphere[0].Radius);
}
[Fact]
public void ScalarInitPath_IsByteForByteUnchanged_PreservingCapturedFixtureReplays()
{
// The pre-TS-46 scalar overload is now the degenerate 2-scalar case
// of the sphere-list overload — its OUTPUT must be identical to the
// pre-port behavior (the reconstructed 5 mm-off pair), because every
// captured-fixture replay test (CellarUpTrajectoryReplayTests,
// DoorBugTrajectoryReplayTests, CellarLipWedgeTests) calls this exact
// overload with recorded scalar values and must keep passing
// unmodified.
var sp = new SpherePath();
sp.InitPath(
begin: Vector3.Zero,
end: Vector3.Zero,
cellId: TestCellId,
sphereRadius: 0.48f,
sphereHeight: 1.835f);
Assert.Equal(2, sp.NumSphere);
Assert.Equal(new Vector3(0f, 0f, 0.48f), sp.LocalSphere[0].Origin);
Assert.Equal(0.48f, sp.LocalSphere[0].Radius);
// The reconstruction's own arithmetic: height radius = 1.835 0.48 = 1.355.
Assert.Equal(new Vector3(0f, 0f, 1.355f), sp.LocalSphere[1].Origin);
Assert.Equal(0.48f, sp.LocalSphere[1].Radius);
// And this is genuinely DIFFERENT from the dat-exact sphere list —
// documents the 5 mm residual the TS-46 register row described.
Assert.NotEqual(1.350f, sp.LocalSphere[1].Origin.Z);
}
/// <summary>
/// Integration-level proof that <see cref="PhysicsEngine.ResolveWithTransition"/>'s
/// <c>sphereList</c> parameter actually drives the sweep, not just
/// <see cref="SpherePath.InitPath"/> in isolation. A tiny DECOY
/// (sphereRadius, sphereHeight) capsule that could never reach a
/// head-height obstacle is passed ALONGSIDE the real human sphere list;
/// if the engine silently preferred the decoy scalars over the supplied
/// list, the mover would sail through untouched.
/// </summary>
[Fact]
public void ResolveWithTransition_HonorsSphereListOverScalarDecoyWhenBothSupplied()
{
var engine = BuildEngine();
const float HeadZ = 1.350f; // human Setup 0x02000001 Spheres[1].Origin.Z
RegisterObstacleSphere(engine, 0xD0D0u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f);
Vector3 pos = new(12f, 10f, 0f);
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
// DECOY scalars: a 5 cm radius / 10 cm capsule cannot reach
// Z=1.35 no matter what — if the engine used THESE, the
// mover would pass straight through the obstacle.
sphereRadius: 0.05f,
sphereHeight: 0.10f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: grounded,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
sphereList: HumanSetupSpheres,
sphereScale: 1f);
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
// Surface contact at Y = 11.0 (0.48 + 0.30) = 10.22.
Assert.True(pos.Y < 10.6f,
"Supplying the real human sphere list must block the mover's head "
+ $"sphere at the head-height obstacle, proving sphereList (not the "
+ $"decoy scalar) drove the sweep; got Y={pos.Y:F3}");
Assert.True(pos.Y > 9.9f,
$"The mover must actually reach the obstacle, not stop early; got Y={pos.Y:F3}");
}
/// <summary>
/// Control for the test above: the SAME decoy scalars, but with
/// <c>sphereList</c> omitted (empty/default). The decoy capsule can
/// never reach the head-height obstacle, so the mover must sail through
/// untouched — proving the block above genuinely comes from the
/// supplied sphere list, not some unrelated artifact of the obstacle
/// registration.
/// </summary>
[Fact]
public void ResolveWithTransition_EmptySphereList_FallsBackToScalarReconstruction()
{
var engine = BuildEngine();
const float HeadZ = 1.350f;
RegisterObstacleSphere(engine, 0xD0D1u, x: 12f, y: 11.0f, z: HeadZ, radius: 0.30f);
Vector3 pos = new(12f, 10f, 0f);
uint cellId = TestCellId;
bool grounded = true;
var perTick = new Vector3(0f, 0.08f, 0f);
for (int tick = 0; tick < 30; tick++)
{
var result = engine.ResolveWithTransition(
pos, pos + perTick, cellId,
sphereRadius: 0.05f,
sphereHeight: 0.10f,
stepUpHeight: 0.4f,
stepDownHeight: 0.4f,
isOnGround: grounded,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide);
// sphereList omitted — default empty, legacy scalar reconstruction.
pos = result.Position;
cellId = result.CellId;
grounded = result.IsOnGround;
}
_out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})");
Assert.True(pos.Y > 11.9f,
"With no sphereList supplied, the decoy capsule (too short to reach "
+ $"the head-height obstacle) must sail through untouched; got Y={pos.Y:F3}");
}
private static PhysicsEngine BuildEngine()
{
var cache = new PhysicsDataCache();
var engine = new PhysicsEngine { DataCache = cache };
var heights = new byte[81];
var heightTable = new float[256]; // all zero → terrain Z = 0
engine.AddLandblock(
landblockId: TestLandblockId,
terrain: new TerrainSurface(heights, heightTable),
cells: Array.Empty<CellSurface>(),
portals: Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
private static void RegisterObstacleSphere(
PhysicsEngine engine, uint entityId, float x, float y, float z, float radius)
{
engine.ShadowObjects.Register(
entityId, gfxObjId: 0u,
new Vector3(x, y, z), Quaternion.Identity, radius,
worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId,
collisionType: ShadowCollisionType.Sphere,
cylHeight: 0f, scale: 1f,
state: 0u,
flags: EntityCollisionFlags.IsCreature,
isStatic: false);
}
}