feat(physics): P5 commit 1 - port ConstraintManager leash distance constants (#167)

Add ConstraintDistance (outdoor/indoor start=10/5, max=50/20), byte-decoded
from the matching retail binary (GetStartConstraintDistance 0x0050ebc0,
GetMaxConstraintDistance 0x0050ec10 - both x87-return functions BN elided).
Deliberately omits the vestigial player-vs-remote branch the disassembly
shows loads identical constants either way. Pins the ACE-inversion (ACE's
start mapping is outdoor 5/indoor 10, the opposite of the binary - the
binary wins). Adds a full-chain conformance test proving an armed,
over-strained leash actually blocks jump_is_allowed (0x47), not just the
bare stub-property regression already covered.

See docs/research/2026-07-30-constraint-leash-constants.md.
This commit is contained in:
Erik 2026-07-30 11:54:35 +02:00
parent b2b44954d7
commit e0629145ef
3 changed files with 169 additions and 0 deletions

View file

@ -0,0 +1,72 @@
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// Campaign P P5 (#167 / TS-35) golden values for
/// <see cref="ConstraintDistance"/> — byte-decoded from the matching retail
/// binary (docs/research/2026-07-30-constraint-leash-constants.md §1), not
/// guessed and not copied from ACE (whose start mapping is INVERTED).
/// </summary>
public sealed class ConstraintDistanceTests
{
// Outdoor landblock cell indices are 0x0001-0x00FF (low16 < 0x0100).
private const uint OutdoorCell = 0x12340007u;
// EnvCell (indoor) indices are 0x0100+ .
private const uint IndoorCell = 0x12340105u;
[Fact]
public void GetStartConstraintDistance_Outdoor_Is10NotAcesInverted5()
{
// ACE PhysicsObj.cs:620 maps outdoor start -> 5. The disassembly
// (0x0050ebc0) says 10. The binary wins.
Assert.Equal(10.0f, ConstraintDistance.GetStartConstraintDistance(OutdoorCell));
}
[Fact]
public void GetStartConstraintDistance_Indoor_Is5()
{
Assert.Equal(5.0f, ConstraintDistance.GetStartConstraintDistance(IndoorCell));
}
[Fact]
public void GetMaxConstraintDistance_Outdoor_Is50()
{
Assert.Equal(50.0f, ConstraintDistance.GetMaxConstraintDistance(OutdoorCell));
}
[Fact]
public void GetMaxConstraintDistance_Indoor_Is20()
{
Assert.Equal(20.0f, ConstraintDistance.GetMaxConstraintDistance(IndoorCell));
}
[Theory]
[InlineData(0x00FFu, false)]
[InlineData(0x0100u, true)]
[InlineData(0x0000u, false)]
[InlineData(0xFFFFu, true)]
public void IsIndoorCell_BoundaryIsLow16Ox0100(uint objCellId, bool expectedIndoor)
{
Assert.Equal(expectedIndoor, ConstraintDistance.IsIndoorCell(objCellId));
}
// The disassembly's "this == player_object" branch loads IDENTICAL
// constants on both sides (research doc §1, "vestigial" finding) — there
// is deliberately no player/remote parameter on this API. Pin that the
// SAME cell always yields the SAME band regardless of which kind of
// object (player or remote) is asking, by construction (no such
// parameter exists to differentiate them).
[Fact]
public void NoPlayerVsRemoteSplit_SameCellAlwaysYieldsSameBand()
{
float startA = ConstraintDistance.GetStartConstraintDistance(OutdoorCell);
float startB = ConstraintDistance.GetStartConstraintDistance(OutdoorCell);
float maxA = ConstraintDistance.GetMaxConstraintDistance(OutdoorCell);
float maxB = ConstraintDistance.GetMaxConstraintDistance(OutdoorCell);
Assert.Equal(startA, startB);
Assert.Equal(maxA, maxB);
}
}

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Tests.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics;
@ -694,6 +695,47 @@ public sealed class MotionInterpreterJumpFamilyTests
Assert.Equal(WeenieError.GeneralMovementFailure, result);
}
// Campaign P P5 (#167 / TS-35): the FULL production chain — ConstraintManager
// armed via ConstraintDistance's byte-decoded constants, the leash's linear
// taper (ConstraintManager.AdjustOffset) driving ConstraintPosOffset past 90%
// of max, then PhysicsBody.IsFullyConstrained pushed from the read (exactly
// as PlayerMovementController's/RuntimeRemotePhysicsUpdater's per-tick pump
// does) — not just the bare-stub-property test above. Proves the leash
// actually blocks a jump once armed and over-strained, end to end.
[Fact]
public void JumpIsAllowed_LeashArmedAndOverstrained_ReturnsGeneralMovementFailure()
{
var world = new System.Collections.Generic.Dictionary<uint, R5Host>();
var host = new R5Host(10u, world);
const uint outdoorCell = 0x12340007u; // low16 < 0x0100 -> outdoor
host.Position = new Position(outdoorCell, Vector3.Zero, Quaternion.Identity);
float start = ConstraintDistance.GetStartConstraintDistance(outdoorCell);
float max = ConstraintDistance.GetMaxConstraintDistance(outdoorCell);
Assert.Equal(10.0f, start);
Assert.Equal(50.0f, max);
// Arm anchored to the object's own current position (matches the
// production arm sites: offset starts at 0 since anchor == self).
host.PositionManager.ConstrainTo(host.Position, start, max);
Assert.False(host.PositionManager.IsFullyConstrained());
// Drive one large per-tick offset (a single tick's step length, per
// ConstraintManager's retail semantics) past 90% of max (45 m).
var frame = new MotionDeltaFrame { Origin = new Vector3(46f, 0f, 0f) };
host.PositionManager.AdjustOffset(frame, quantum: 1.0 / 30.0);
Assert.True(host.PositionManager.IsFullyConstrained());
var body = MakeGrounded();
// The per-tick pump's push (PlayerMovementController / RuntimeRemotePhysicsUpdater):
body.IsFullyConstrained = host.PositionManager.IsFullyConstrained();
var interp = MakeInterp(body);
var result = interp.jump_is_allowed(0.5f, out _);
Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47
}
[Fact]
public void JumpIsAllowed_PendingHeadNonzeroError_ShortCircuitsChain()
{