feat(physics): R5-V1 — port PositionManager/Sticky/Constraint + TargetManager (Core, unwired)

The retail movement-manager family the R4 MoveToManager port left as
do-not-invent seams (decomp §9f/§9g). Faithful C# ports of retail's
PositionManager facade + StickyManager + ConstraintManager + the
TargetManager voyeur system, with full conformance tests. NO wiring yet
— purely additive, no behavior change. Wiring (retiring TS-39 sticky +
AP-79 target adapter) is R5-V2/V3.

New Core classes (src/AcDream.Core/Physics/Motion/):
- StickyManager (0x00555400): follow-a-target steering. adjust_offset's
  dense x87 mush decoded via ACE (StickyRadius 0.3, StickyTime 1.0,
  follow speed ×5 / fallback 15) — speed-clamped signed-distance steer +
  bounded turn-to-face; 1 s watchdog; Ok→initialized / non-Ok→teardown.
- ConstraintManager (0x00556090): the server-position rubber-band leash.
  90% IsFullyConstrained jump gate + grounded linear brake taper.
  Structural only — acdream never ARMS it (retail arms from
  SmartBox::HandleReceivedPosition, which acdream lacks, with two x87
  constants BN elided). IsFullyConstrained stays false = TS-35 behavior;
  leash-arming + the unknown constants are a deferred issue.
- PositionManager facade (0x00555160): lazy Sticky/Constraint + fan-out.
- TargetManager (0x0051a370) + TargettedVoyeurInfo: the peer-to-peer
  voyeur subscription system (0.5 s throttle, 10 s staleness,
  send-on-drift-past-radius, dead-reckon GetInterpolatedPosition). A
  faithful superset of the AP-79 adapter — SetTarget subscribes ON the
  target; the target's HandleTargetting pushes updates back.
- IPhysicsObjHost: the CPhysicsObj back-pointer seam (position/velocity/
  radius/contact/GetObjectA + target-tracking fan-out) the App wires per
  entity in V2/V3. MotionDeltaFrame: mutable retail-Frame delta accumulator.

Supporting:
- TargetInfo extended to the full retail 10-field struct (additive
  defaults keep the R4 4-arg call sites compiling).
- MoveToMath: signed CylinderDistanceNoZ, NormalizeCheckSmall,
  GlobalToLocalVec.
- Rename: the misnamed AcDream.Core.Physics.PositionManager (a remote
  anim+interp per-frame combiner, NOT the retail facade) → RemoteMotion
  Combiner, freeing the name and removing the ambiguity that breaks every
  file importing both Physics + Physics.Motion (GameWindow will in V2/V3).

Tests: 42 new conformance cases (Sticky/Constraint/Position facade +
TargetManager incl. the full cross-entity voyeur round-trip). Full suite
4006 green (+2 skipped), no regressions.

Decomp + ACE cross-ref + port plan: docs/research/2026-07-03-r5-managers/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 19:34:49 +02:00
parent 517bbfdae4
commit 3d89446d98
25 changed files with 7279 additions and 12 deletions

View file

@ -1,108 +0,0 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-frame combiner for remote-entity motion: animation root motion
/// + InterpolationManager catch-up correction. Pure function — no
/// side effects, no hidden state.
///
/// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730):
/// rootOffset = CPartArray::Update(dt) // animation
/// PositionManager::adjust_offset(rootOffset) // adds correction
/// frame.origin += rootOffset
///
/// In acdream the animation root motion is sourced from
/// AnimationSequencer.CurrentVelocity (body-local velocity from the
/// active locomotion cycle). We rotate that by the body's orientation
/// to get a world-space delta, then add the InterpolationManager's
/// world-space correction.
/// </summary>
public sealed class PositionManager
{
/// <summary>
/// Compute the per-frame world-space delta to add to body.Position.
/// </summary>
/// <param name="dt">Per-frame delta time, seconds.</param>
/// <param name="currentBodyPosition">Body's current world-space position.</param>
/// <param name="seqVel">
/// Body-local velocity from the active animation cycle
/// (from <c>AnimationSequencer.CurrentVelocity</c>); pass
/// <c>Vector3.Zero</c> if the entity has no sequencer or is on a
/// non-locomotion cycle.
/// </param>
/// <param name="ori">Body orientation; used to rotate seqVel from body-local to world.</param>
/// <param name="interp">The remote's InterpolationManager (for AdjustOffset call).</param>
/// <param name="maxSpeed">From <c>MotionInterpreter.GetMaxSpeed()</c> — passed to AdjustOffset for the catch-up clamp.</param>
/// <param name="terrainNormal">
/// Optional local terrain plane normal at the body's current XY. When
/// supplied AND the queue-empty / head-reached fallback path runs, the
/// world-space anim root motion is projected onto the plane so XY motion
/// produces a corresponding Z change on slopes. Without this, the
/// fallback advances XY at the locomotion cycle's pace but leaves Z at
/// the last UP's reported Z — visible as a ~5 Hz staircase on slopes
/// (the rate of server UpdatePositions). Mirrors retail's
/// <c>CTransition::adjust_offset</c> contact-plane projection
/// (named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
/// motion, applied here at the queue-empty boundary instead of inside
/// the sweep. Pass <c>null</c> on flat ground / when no terrain sample
/// is available — projection is a no-op when normal == +Z.
/// </param>
public Vector3 ComputeOffset(
double dt,
Vector3 currentBodyPosition,
Vector3 seqVel,
Quaternion ori,
InterpolationManager interp,
float maxSpeed,
Vector3? terrainNormal = null)
{
// Retail-faithful per-frame combiner. Mirrors
// CPhysicsObj::UpdatePositionInternal (acclient @ 0x00512c30) +
// InterpolationManager::adjust_offset (@ 0x00555d30):
//
// 1. CPartArray::Update writes rootOffset (animation root motion)
// into the per-tick Frame.
// 2. PositionManager::adjust_offset → InterpolationManager::adjust_offset
// either:
// a) RETURNS EARLY when distance(body, head) < 0.05m
// (NodeCompleted; arg2 unmodified) — body uses root motion.
// b) OVERWRITES arg2 with `direction × min(catchUpSpeed × dt,
// distance)` when body is far from head — catch-up REPLACES
// root motion for this frame.
//
// It is NOT additive. Our prior port added rootMotion + correction
// every frame, which stacked the animation push (≈ RunAnimSpeed ×
// speedMod, ≈ 11.7 m/s) on top of the queue catch-up (capped at
// ≈ 23.5 m/s) so the body advanced at up to ~3× the server's
// broadcast pace and the head-behind-body case produced a backward
// correction every UP — the visible 1-Hz blip the user reported.
//
// AdjustOffset returns Vector3.Zero in two cases mapped to retail's
// early-return: empty queue OR distance < DesiredDistance (0.05m).
// In both, body falls back to animation root motion.
Vector3 correction = interp.AdjustOffset(dt, currentBodyPosition, maxSpeed);
if (correction.LengthSquared() > 0f)
return correction;
Vector3 rootMotionLocal = seqVel * (float)dt;
Vector3 rootMotionWorld = Vector3.Transform(rootMotionLocal, ori);
// Slope projection (queue-empty fallback only). Locomotion cycles
// bake Z=0 in body-local, so without projection the body's Z stays
// at the last UP's reported value while XY advances at the running
// pace — visible ~5 Hz staircase between UPs on hills. Projecting
// the world-space anim motion onto the local terrain plane gives
// it a Z component proportional to slope × forward speed, so the
// body follows the terrain mesh smoothly. No-op on flat ground
// (normal ≈ +Z, dot ≈ 0) so it can't regress the M2 flat-ground
// verification.
if (terrainNormal.HasValue && terrainNormal.Value.Z > 0.01f)
{
Vector3 N = terrainNormal.Value;
float into = Vector3.Dot(rootMotionWorld, N);
rootMotionWorld -= N * into;
}
return rootMotionWorld;
}
}