acdream/src/AcDream.App/Physics/LiveEntityOrdinaryPhysicsUpdater.cs
Erik dae5b1ea68 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>
2026-07-30 09:05:44 +02:00

118 lines
4.1 KiB
C#

using System.Collections.Immutable;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using AcDream.Runtime.Physics;
using DatReaderWriter.Types;
namespace AcDream.App.Physics;
/// <summary>
/// Projects the presentation-free Runtime result of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (0x005156B0) into App's
/// <see cref="WorldEntity"/> and spatial buckets.
/// </summary>
internal sealed class LiveEntityOrdinaryPhysicsUpdater
{
private readonly RuntimeOrdinaryPhysicsUpdater _runtime;
private readonly Func<uint, WorldEntity, (float Radius, float Height)>
_getSetupCylinder;
private readonly Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
_getSetupMoverShape;
public LiveEntityOrdinaryPhysicsUpdater(
RuntimePhysicsState physics,
Func<uint, WorldEntity, (float Radius, float Height)> getSetupCylinder,
Func<uint, WorldEntity,
(ImmutableArray<FlatCollisionSphere> Spheres, float Scale, float StepUpHeight, float StepDownHeight)>
getSetupMoverShape)
{
_runtime = new RuntimeOrdinaryPhysicsUpdater(
physics ?? throw new ArgumentNullException(nameof(physics)));
_getSetupCylinder = getSetupCylinder
?? throw new ArgumentNullException(nameof(getSetupCylinder));
_getSetupMoverShape = getSetupMoverShape
?? throw new ArgumentNullException(nameof(getSetupMoverShape));
}
public bool Tick(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
Frame rootFrame,
float objectScale,
float quantum,
int liveCenterX,
int liveCenterY,
ulong objectClockEpoch,
AnimationSequencer? sequencer,
Action<uint, AnimationSequencer> captureAnimationHooks)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(rootFrame);
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
if (record.PhysicsBody is not { } body)
return false;
var (radius, height) = _getSetupCylinder(record.ServerGuid, entity);
var shape = _getSetupMoverShape(record.ServerGuid, entity);
bool ExternalOwnerValid() =>
IsCurrent(
runtime,
record,
entity,
body,
objectClockEpoch);
if (!_runtime.TryBegin(
record.Canonical,
rootFrame,
objectScale,
quantum,
radius,
height,
objectClockEpoch,
sequencer,
captureAnimationHooks,
ExternalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit,
sphereList: shape.Spheres,
sphereScale: shape.Scale,
stepUpHeight: shape.StepUpHeight,
stepDownHeight: shape.StepDownHeight))
{
return false;
}
return _runtime.Complete(
commit,
liveCenterX,
liveCenterY,
snapshot =>
{
if (!ExternalOwnerValid())
return false;
entity.SetPosition(snapshot.Position);
entity.Rotation = snapshot.Orientation;
entity.ParentCellId = snapshot.FullCellId;
return ExternalOwnerValid();
});
}
private static bool IsCurrent(
LiveEntityRuntime runtime,
LiveEntityRecord record,
WorldEntity entity,
PhysicsBody body,
ulong objectClockEpoch) =>
runtime.IsCurrentSpatialRootObject(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.WorldEntity, entity)
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotionRuntime is null
&& record.ProjectileRuntime is null;
}