From e0629145ef8abe3eb749f246f42979c53ed43011 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 11:54:35 +0200 Subject: [PATCH] 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. --- .../Physics/Motion/ConstraintDistance.cs | 55 ++++++++++++++ .../Physics/Motion/ConstraintDistanceTests.cs | 72 +++++++++++++++++++ .../MotionInterpreterJumpFamilyTests.cs | 42 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/AcDream.Core/Physics/Motion/ConstraintDistance.cs create mode 100644 tests/AcDream.Core.Tests/Physics/Motion/ConstraintDistanceTests.cs 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() {