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:
parent
b2b44954d7
commit
e0629145ef
3 changed files with 169 additions and 0 deletions
55
src/AcDream.Core/Physics/Motion/ConstraintDistance.cs
Normal file
55
src/AcDream.Core/Physics/Motion/ConstraintDistance.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P P5 (#167 / register TS-35) — retail
|
||||
/// <c>CPhysicsObj::GetStartConstraintDistance</c> (0x0050ebc0) and
|
||||
/// <c>GetMaxConstraintDistance</c> (0x0050ec10): the leash's start/max band,
|
||||
/// keyed by whether the object's OWN current cell is outdoor or indoor.
|
||||
///
|
||||
/// <para><b>Byte-decoded, not guessed</b> — BN elided both getters' return
|
||||
/// value as a bare <c>this->m_position;</c> expression (the classic x87
|
||||
/// FPU-return decompile artifact). The actual values were recovered by
|
||||
/// disassembling the matching binary's raw machine code (see
|
||||
/// <c>docs/research/2026-07-30-constraint-leash-constants.md</c> §1):
|
||||
/// outdoor start = <b>10.0</b>, indoor start = <b>5.0</b>; outdoor max =
|
||||
/// <b>50.0</b>, indoor max = <b>20.0</b>. Indoor is decided by the object's
|
||||
/// full cell id's low 16 bits: <c>>= 0x0100</c> = EnvCell (indoor).</para>
|
||||
///
|
||||
/// <para><b>The retail player-vs-remote branch is INTENTIONALLY OMITTED.</b>
|
||||
/// The disassembly shows both getters branch on <c>this == player_object</c>
|
||||
/// 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.</para>
|
||||
///
|
||||
/// <para><b>ACE-inversion pin.</b> ACE's <c>PhysicsObj.GetStartConstraintDistance</c>
|
||||
/// (<c>PhysicsObj.cs:620</c>) 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Retail <c>cmp eax, 0x100; jae indoor</c> on the object's own
|
||||
/// <c>m_position.objcell_id & 0xFFFF</c> — low 16 bits ≥ 0x0100 is an
|
||||
/// EnvCell (indoor); below that is an outdoor landblock cell index.</summary>
|
||||
public static bool IsIndoorCell(uint objCellId) => (objCellId & 0xFFFFu) >= 0x0100u;
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::GetStartConstraintDistance</c>
|
||||
/// (0x0050ebc0) — the near edge of the leash's brake band, keyed by the
|
||||
/// object's own current cell.</summary>
|
||||
public static float GetStartConstraintDistance(uint objCellId) =>
|
||||
IsIndoorCell(objCellId) ? IndoorStart : OutdoorStart;
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::GetMaxConstraintDistance</c>
|
||||
/// (0x0050ec10) — the far edge (full clamp), keyed by the object's own
|
||||
/// current cell.</summary>
|
||||
public static float GetMaxConstraintDistance(uint objCellId) =>
|
||||
IsIndoorCell(objCellId) ? IndoorMax : OutdoorMax;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue