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

@ -1,3 +1,5 @@
using System.Collections.Immutable;
using System.Linq;
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.Net;
@ -370,7 +372,7 @@ internal sealed class PlayerModeController :
+ $"run={_skills.RunSkill} jump={_skills.JumpSkill}");
}
ApplyStepHeights(controller, playerEntity);
ApplyStepHeights(controller, playerEntity, playerGuid);
uint initialCellId = ResolveInitialCell(playerGuid, playerEntity);
Action? drainPriorAnimationQueue = null;
@ -532,7 +534,8 @@ internal sealed class PlayerModeController :
private void ApplyStepHeights(
PlayerMovementController controller,
WorldEntity playerEntity)
WorldEntity playerEntity,
uint playerGuid)
{
if ((playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
@ -544,22 +547,48 @@ internal sealed class PlayerModeController :
_collisionAssets.CacheSetup(
playerEntity.SourceGfxObjOrSetupId,
setup);
// TS-46 (2026-07-30): CPartArray::GetStepUpHeight/GetStepDownHeight
// (0x005180d0/0x005180f0) return setup->step_up_height * this->scale
// — apply the same ObjScale multiply the remote/ordinary paths now
// use (LiveEntityMotionRuntimeController.GetSetupMoverShape), for
// parity on a non-1.0-scale player (a rare but real case — e.g. a
// disguise/size-changing effect). Human ObjScale is 1.0 in the
// overwhelming common case, so this is a no-op there.
float scale =
_liveEntities.Snapshots.TryGetValue(playerGuid, out var sp)
&& sp.ObjScale is { } objScale && objScale > 0f
? objScale
: (playerEntity.Scale > 0f ? playerEntity.Scale : 1f);
controller.StepUpHeight = setup is { StepUpHeight: > 0f }
? setup.StepUpHeight
? setup.StepUpHeight * scale
: 0.4f;
controller.StepDownHeight = setup is { StepDownHeight: > 0f }
? setup.StepDownHeight
? setup.StepDownHeight * scale
: 0.4f;
// TS-46 (2026-07-30): the Setup's own ≤2-sphere list, verbatim —
// retail CPhysicsObj::transition (0x00512dc0) seeds the sweep
// from CPartArray::GetSphere, not a (radius, height) capsule
// reconstruction. Empty (no Setup, or a Setup with no sphere
// rows) leaves SphereList at its default empty value, which
// ResolveWithTransition treats as "use the legacy scalar
// reconstruction."
controller.SphereList = setup?.Spheres is { Count: > 0 } spheres
? spheres
.Select(s => new FlatCollisionSphere(s.Origin, s.Radius))
.ToImmutableArray()
: ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
$"physics: player step heights — StepUp={controller.StepUpHeight:F3} m "
+ $"(Setup.StepUpHeight={(setup?.StepUpHeight ?? 0f):F3}), "
+ $"StepDown={controller.StepDownHeight:F3} m "
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3})");
+ $"(Setup.StepDownHeight={(setup?.StepDownHeight ?? 0f):F3}), "
+ $"Spheres={controller.SphereList.Length}");
return;
}
controller.StepUpHeight = 0.4f;
controller.StepDownHeight = 0.4f;
controller.SphereList = ImmutableArray<FlatCollisionSphere>.Empty;
Console.WriteLine(
"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}