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:
parent
3dc10accb0
commit
dae5b1ea68
21 changed files with 648 additions and 50 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue