diff --git a/src/AcDream.Core/Physics/Motion/ConstraintDistance.cs b/src/AcDream.Core/Physics/Motion/ConstraintDistance.cs
new file mode 100644
index 00000000..a0cdba24
--- /dev/null
+++ b/src/AcDream.Core/Physics/Motion/ConstraintDistance.cs
@@ -0,0 +1,55 @@
+namespace AcDream.Core.Physics.Motion;
+
+///
+/// Campaign P P5 (#167 / register TS-35) — retail
+/// CPhysicsObj::GetStartConstraintDistance (0x0050ebc0) and
+/// GetMaxConstraintDistance (0x0050ec10): the leash's start/max band,
+/// keyed by whether the object's OWN current cell is outdoor or indoor.
+///
+/// Byte-decoded, not guessed — BN elided both getters' return
+/// value as a bare this->m_position; expression (the classic x87
+/// FPU-return decompile artifact). The actual values were recovered by
+/// disassembling the matching binary's raw machine code (see
+/// docs/research/2026-07-30-constraint-leash-constants.md §1):
+/// outdoor start = 10.0, indoor start = 5.0; outdoor max =
+/// 50.0, indoor max = 20.0. Indoor is decided by the object's
+/// full cell id's low 16 bits: >= 0x0100 = EnvCell (indoor).
+///
+/// The retail player-vs-remote branch is INTENTIONALLY OMITTED.
+/// The disassembly shows both getters branch on this == player_object
+/// AND load byte-identical constants either way — the branch is vestigial
+/// (both sides return the same four numbers). Porting that dead branch would
+/// only add a phantom "is this the player" parameter with zero behavioral
+/// effect, so this class keys purely on the object's own cell id.
+///
+/// ACE-inversion pin. ACE's PhysicsObj.GetStartConstraintDistance
+/// (PhysicsObj.cs:620) maps outdoor → 5, indoor → 10 — the OPPOSITE of
+/// the binary. ACE's max mapping (outdoor 50 / indoor 20) matches. Do not
+/// "fix" this class to match ACE's start mapping; the disassembly is the
+/// oracle here (feedback_acme_oracle: binary wins over ACE on disagreement).
+///
+///
+public static class ConstraintDistance
+{
+ private const float OutdoorStart = 10.0f;
+ private const float IndoorStart = 5.0f;
+ private const float OutdoorMax = 50.0f;
+ private const float IndoorMax = 20.0f;
+
+ /// Retail cmp eax, 0x100; jae indoor on the object's own
+ /// m_position.objcell_id & 0xFFFF — low 16 bits ≥ 0x0100 is an
+ /// EnvCell (indoor); below that is an outdoor landblock cell index.
+ public static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
+
+ /// Retail CPhysicsObj::GetStartConstraintDistance
+ /// (0x0050ebc0) — the near edge of the leash's brake band, keyed by the
+ /// object's own current cell.
+ public static float GetStartConstraintDistance(uint objCellId) =>
+ IsIndoorCell(objCellId) ? IndoorStart : OutdoorStart;
+
+ /// Retail CPhysicsObj::GetMaxConstraintDistance
+ /// (0x0050ec10) — the far edge (full clamp), keyed by the object's own
+ /// current cell.
+ public static float GetMaxConstraintDistance(uint objCellId) =>
+ IsIndoorCell(objCellId) ? IndoorMax : OutdoorMax;
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/ConstraintDistanceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/ConstraintDistanceTests.cs
new file mode 100644
index 00000000..98fa14a7
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Motion/ConstraintDistanceTests.cs
@@ -0,0 +1,72 @@
+using AcDream.Core.Physics.Motion;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics.Motion;
+
+///
+/// Campaign P P5 (#167 / TS-35) golden values for
+/// — 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).
+///
+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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs
index f32d67e7..7271439a 100644
--- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs
@@ -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();
+ 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()
{