acdream/src/AcDream.Runtime/Physics/RuntimeOrdinaryPhysicsUpdater.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

284 lines
9.9 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Runtime.Entities;
using DatReaderWriter.Types;
namespace AcDream.Runtime.Physics;
internal readonly record struct RuntimePhysicsFrameSnapshot(
Vector3 Position,
Quaternion Orientation,
uint FullCellId);
internal sealed class RuntimeOrdinaryPhysicsCommit
{
internal required RuntimeOrdinaryPhysicsUpdater Owner { get; init; }
internal required RuntimeEntityRecord Record { get; init; }
internal required PhysicsBody Body { get; init; }
internal required ulong ObjectClockEpoch { get; init; }
internal required bool FrameChanged { get; init; }
internal required Func<bool>? ExternalOwnerValid { get; init; }
internal bool Completed { get; set; }
internal RuntimePhysicsFrameSnapshot Snapshot { get; init; }
}
/// <summary>
/// Presentation-free, body-backed branch of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c> (`0x005156B0`). App supplies the
/// completed animation root frame and acknowledges the resulting projection;
/// Runtime owns integration, transition, canonical-body state, and shadows.
/// </summary>
internal sealed class RuntimeOrdinaryPhysicsUpdater
{
private readonly RuntimePhysicsState _physics;
internal RuntimeOrdinaryPhysicsUpdater(RuntimePhysicsState physics)
{
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
internal bool TryBegin(
RuntimeEntityRecord record,
Frame rootFrame,
float objectScale,
float quantum,
float radius,
float height,
ulong objectClockEpoch,
AnimationSequencer? sequencer,
Action<uint, AnimationSequencer> captureAnimationHooks,
Func<bool>? externalOwnerValid,
out RuntimeOrdinaryPhysicsCommit commit,
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list + Setup-derived
// step heights (LiveEntityMotionRuntimeController.GetSetupMoverShape).
// Default/empty preserves the pre-TS-46 0.4 m literal fallback below.
System.Collections.Immutable.ImmutableArray<FlatCollisionSphere>
sphereList = default,
float sphereScale = 1f,
float stepUpHeight = 0.4f,
float stepDownHeight = 0.4f)
{
ArgumentNullException.ThrowIfNull(record);
ArgumentNullException.ThrowIfNull(rootFrame);
ArgumentNullException.ThrowIfNull(captureAnimationHooks);
if (record.PhysicsBody is not { } body
|| !IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
body.State = record.FinalPhysicsState;
Vector3 priorPosition = body.Position;
Quaternion priorOrientation = body.Orientation;
bool previousContact = body.InContact;
bool previousOnWalkable = body.OnWalkable;
// UpdatePositionInternal 0x00512CA1: scale root translation only while
// OnWalkable. Complete root orientation composes regardless.
Vector3 candidatePosition = priorPosition;
if (body.OnWalkable && rootFrame.Origin != Vector3.Zero)
{
candidatePosition += Vector3.Transform(
rootFrame.Origin * objectScale,
priorOrientation);
}
Quaternion candidateOrientation = priorOrientation;
if (!rootFrame.Orientation.IsIdentity)
{
candidateOrientation = FrameOps.SetRotate(
candidatePosition,
priorOrientation,
priorOrientation * rootFrame.Orientation);
}
body.SetFrameInCurrentCell(candidatePosition, candidateOrientation);
body.calc_acceleration();
body.UpdatePhysicsInternal(quantum);
body.SetFrameInCurrentCell(body.Position, body.Orientation);
if (sequencer is not null)
{
uint localId = record.LocalEntityId
?? throw new InvalidOperationException(
$"Runtime entity 0x{record.ServerGuid:X8}/{record.Incarnation} has no local identity.");
captureAnimationHooks(localId, sequencer);
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
Vector3 integratedPosition = body.Position;
uint sourceCellId = record.FullCellId;
uint resolvedCellId = sourceCellId;
bool frameChanged = integratedPosition != priorPosition
|| body.Orientation != priorOrientation;
uint movingEntityId = record.LocalEntityId ?? 0u;
if (integratedPosition != priorPosition
&& sourceCellId != 0
&& radius >= 0.05f
&& _physics.Engine.LandblockCount > 0)
{
ResolveResult resolved = _physics.Engine.ResolveWithTransition(
priorPosition,
integratedPosition,
sourceCellId,
radius,
height,
stepUpHeight: stepUpHeight, // TS-46: Setup-derived, was a 0.4f literal
stepDownHeight: stepDownHeight, // TS-46: Setup-derived, was a 0.4f literal
isOnGround: previousOnWalkable,
body: body,
moverFlags: IsPlayerGuid(record.ServerGuid)
? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide
: ObjectInfoState.EdgeSlide,
movingEntityId: movingEntityId,
// TS-46: the Setup's own sphere list, scaled by ObjScale.
// Empty falls back to the radius/height reconstruction above.
sphereList: sphereList,
sphereScale: sphereScale);
if (resolved.Ok)
{
resolvedCellId = resolved.CellId != 0
? resolved.CellId
: sourceCellId;
body.CommitTransitionPosition(
resolvedCellId,
resolved.Position);
PhysicsObjUpdate.CommitSetPositionTransition(
body,
resolved.InContact,
resolved.OnWalkable,
resolved.CollisionNormalValid,
resolved.CollisionNormal,
previousContact,
previousOnWalkable);
body.CachedVelocity = quantum > 0f
? (body.Position - priorPosition) / quantum
: Vector3.Zero;
}
else
{
body.CachedVelocity = Vector3.Zero;
}
}
else
{
body.CachedVelocity = Vector3.Zero;
}
if (!IsCurrent(
record,
body,
objectClockEpoch,
externalOwnerValid))
{
commit = null!;
return false;
}
commit = new RuntimeOrdinaryPhysicsCommit
{
Owner = this,
Record = record,
Body = body,
ObjectClockEpoch = objectClockEpoch,
FrameChanged = frameChanged,
ExternalOwnerValid = externalOwnerValid,
Snapshot = new RuntimePhysicsFrameSnapshot(
body.Position,
body.Orientation,
resolvedCellId),
};
return true;
}
internal bool Complete(
RuntimeOrdinaryPhysicsCommit commit,
int liveCenterX,
int liveCenterY,
Func<RuntimePhysicsFrameSnapshot, bool> acknowledgeProjection)
{
ArgumentNullException.ThrowIfNull(commit);
ArgumentNullException.ThrowIfNull(acknowledgeProjection);
if (!ReferenceEquals(commit.Owner, this))
{
throw new InvalidOperationException(
"An ordinary-physics commit belongs to another Runtime owner.");
}
if (commit.Completed)
{
throw new InvalidOperationException(
"An ordinary-physics commit has already completed.");
}
commit.Completed = true;
if (!IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid)
|| !_physics.CommitOrdinaryCell(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.Snapshot.FullCellId,
commit.ExternalOwnerValid)
|| !acknowledgeProjection(commit.Snapshot)
|| !IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid))
{
return false;
}
if (commit.FrameChanged
&& commit.Record.FullCellId != 0)
{
ShadowPositionSynchronizer.Sync(
_physics.Engine.ShadowObjects,
commit.Record.LocalEntityId ?? 0u,
commit.Body.Position,
commit.Body.Orientation,
commit.Record.FullCellId,
liveCenterX,
liveCenterY);
}
return IsCurrent(
commit.Record,
commit.Body,
commit.ObjectClockEpoch,
commit.ExternalOwnerValid);
}
private bool IsCurrent(
RuntimeEntityRecord record,
PhysicsBody body,
ulong objectClockEpoch,
Func<bool>? externalOwnerValid) =>
_physics.IsSpatialRoot(record)
&& record.ObjectClockEpoch == objectClockEpoch
&& ReferenceEquals(record.PhysicsBody, body)
&& record.RemoteMotion is null
&& (externalOwnerValid?.Invoke() ?? true);
private static bool IsPlayerGuid(uint guid) =>
(guid & 0xFF000000u) == 0x50000000u;
}