fix(items): use retail cylinder range for loot windows

Keep external-container visibility tied to the signed surface gap between the player and container physics cylinders, matching gmExternalContainerUI's authored UseRadius range watcher. This prevents a valid ViewContents response from being closed at ACE's natural approach endpoint.

Port Position::cylinder_distance and ACCWeenieObject::ObjectsInRange into Core with conformance coverage for 3-D separation, overlap, mode precedence, and inclusive boundaries.

Release build succeeds and all 5,895 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 20:32:34 +02:00
parent e6dd8bf6fa
commit 0f82a08f0a
6 changed files with 199 additions and 50 deletions

View file

@ -15,6 +15,11 @@ Named Sept-2013 retail symbols:
- `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0` - `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0`
- `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0` - `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0`
- `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0` - `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0`
- `CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360`
- `CPlayerSystem::RegisterObjectRangeHandler @ 0x005602D0`
- `ACCWeenieObject::ObjectsInRange @ 0x0058C1A0`
- `CPhysicsObj::get_distance_to_object @ 0x0050F7A0`
- `Position::cylinder_distance @ 0x005A97F0`
- `gmExternalContainerUI::PostInit @ 0x004CBDE0` - `gmExternalContainerUI::PostInit @ 0x004CBDE0`
- `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50` - `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50`
- `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0` - `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0`
@ -128,9 +133,19 @@ ExternalPanel.SetGroundObject(containerId):
if containerId == 0: if containerId == 0:
hide panel hide panel
else: else:
register range watch using root UseRadius register range watch(root, root.UseRadius,
useRadii = true, ignoreZDelta = false)
show panel show panel
CalculateExternalRangeWatch(root, player):
// CPlayerSystem routes the authored range registration through
// ACCWeenieObject::ObjectsInRange. With useRadii=true, this is the gap
// between the two live physics cylinders, not center-to-center distance.
distance = Position.cylinder_distance(
root.radius, root.height, root.position,
player.radius, player.height, player.position)
return distance <= root.UseRadius
ExternalPanel.CloseCurrentContainer(): ExternalPanel.CloseCurrentContainer():
if currentExternalRoot == 0: if currentExternalRoot == 0:
return return

View file

@ -13529,6 +13529,7 @@ public sealed class GameWindow : IDisposable
private bool IsWithinExternalContainerUseRange(uint targetGuid) private bool IsWithinExternalContainerUseRange(uint targetGuid)
{ {
if (_playerController is null if (_playerController is null
|| !_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity)) || !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
{ {
// The server remains authoritative for forced close. An entity can be // The server remains authoritative for forced close. An entity can be
@ -13540,10 +13541,24 @@ public sealed class GameWindow : IDisposable
if (!LastSpawns.TryGetValue(targetGuid, out var spawn) if (!LastSpawns.TryGetValue(targetGuid, out var spawn)
|| spawn.UseRadius is not > 0f) || spawn.UseRadius is not > 0f)
return true; return true;
float useRadius = spawn.UseRadius.Value;
float dx = entity.Position.X - _playerController.Position.X; // gmExternalContainerUI::SetGroundObject @ 0x004CBBD0 registers the
float dy = entity.Position.Y - _playerController.Position.Y; // root's UseRadius with useRadii=true and ignoreZDelta=false.
return dx * dx + dy * dy <= useRadius * useRadius; // CPlayerSystem::CalculateObjectRangeChecks @ 0x0055F360 then calls
// ACCWeenieObject::ObjectsInRange @ 0x0058C1A0, whose use-radii branch
// compares the gap between both physics cylinders, not their centers.
var playerCylinder = GetSetupCylinder(_playerServerGuid, playerEntity);
var targetCylinder = GetSetupCylinder(targetGuid, entity);
return AcDream.Core.Physics.ObjectRangeMath.ObjectsInRange(
_playerController.Position,
playerCylinder.Radius,
playerCylinder.Height,
entity.Position,
targetCylinder.Radius,
targetCylinder.Height,
spawn.UseRadius.Value,
useRadii: true,
ignoreZDelta: false);
} }
private void InstallSpeculativeTurnToTarget(uint targetGuid) private void InstallSpeculativeTurnToTarget(uint targetGuid)

View file

@ -203,31 +203,30 @@ public static class MoveToMath
/// (wire bit 0x400) is set — object moves use edge-to-edge cylinder /// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
/// distance; position moves use plain center (Euclidean) distance /// distance; position moves use plain center (Euclidean) distance
/// instead (not ported here — <c>Vector3.Distance</c> already covers /// instead (not ported here — <c>Vector3.Distance</c> already covers
/// it). BN garbles the x87 plumbing in the raw, so the exact /// it). Binary Ninja garbles the x87 result plumbing, but the complete
/// radius-combination arithmetic is not directly visible; ported per /// branch structure is visible in
/// the PDB argument ORDER (own radius/height/position, target /// <c>Position::cylinder_distance @ 0x005A97F0</c> and is independently
/// radius/height/position) with the standard cylinder-distance shape: /// preserved by ACE <c>Physics.Common.Position.CylinderDistance</c>:
/// planar (X/Y) center distance minus the sum of both radii, clamped /// subtract both radii from the full 3D center distance, compute the
/// at zero (overlapping cylinders report 0, never negative). Height is /// signed vertical gap between the two cylinders, then combine gaps only
/// accepted for signature parity with the retail call (own/target /// when their signs agree. Overlap is therefore a negative distance
/// height feed the caller's contact-plane logic elsewhere) but does /// rather than a value clamped to zero.
/// NOT participate in this edge-to-edge planar computation — matching
/// retail's use of cylinder_distance purely for the horizontal arrival
/// gate.
/// </summary> /// </summary>
public static float CylinderDistance( public static float CylinderDistance(
float ownRadius, float ownHeight, Vector3 ownPos, float ownRadius, float ownHeight, Vector3 ownPos,
float targetRadius, float targetHeight, Vector3 targetPos) float targetRadius, float targetHeight, Vector3 targetPos)
{ {
_ = ownHeight; float radialGap = Vector3.Distance(ownPos, targetPos)
_ = targetHeight; - (ownRadius + targetRadius);
float verticalGap = ownPos.Z <= targetPos.Z
? targetPos.Z - (ownPos.Z + ownHeight)
: ownPos.Z - (targetPos.Z + targetHeight);
float dx = targetPos.X - ownPos.X; if (verticalGap > 0f && radialGap > 0f)
float dy = targetPos.Y - ownPos.Y; return MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap);
float centerDist = MathF.Sqrt(dx * dx + dy * dy); if (verticalGap < 0f && radialGap < 0f)
return -MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap);
float edgeDist = centerDist - ownRadius - targetRadius; return radialGap;
return edgeDist > 0f ? edgeDist : 0f;
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,53 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail object-range predicate used by <c>CPlayerSystem</c> range handlers.
/// Port of <c>ACCWeenieObject::ObjectsInRange @ 0x0058C1A0</c>.
/// </summary>
public static class ObjectRangeMath
{
/// <summary>
/// Tests two live physics objects against a caller-supplied range. Retail
/// gives <paramref name="ignoreZDelta"/> precedence; otherwise
/// <paramref name="useRadii"/> selects cylinder-gap distance instead of
/// ordinary center distance.
/// </summary>
public static bool ObjectsInRange(
Vector3 firstPosition,
float firstRadius,
float firstHeight,
Vector3 secondPosition,
float secondRadius,
float secondHeight,
double range,
bool useRadii,
bool ignoreZDelta)
{
double distance;
if (ignoreZDelta)
{
double dx = secondPosition.X - firstPosition.X;
double dy = secondPosition.Y - firstPosition.Y;
distance = Math.Sqrt(dx * dx + dy * dy);
}
else if (useRadii)
{
distance = MoveToMath.CylinderDistance(
firstRadius,
firstHeight,
firstPosition,
secondRadius,
secondHeight,
secondPosition);
}
else
{
distance = Vector3.Distance(firstPosition, secondPosition);
}
return distance <= range;
}
}

View file

@ -17,15 +17,10 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// available, not re-ported here). /// available, not re-ported here).
/// ///
/// <para> /// <para>
/// The retail signature's exact combination math for radius/height beyond /// The exact branch structure is visible at named-retail
/// "edge-to-edge, own+target cylinders" is not spelled out in the raw (BN /// <c>Position::cylinder_distance @ 0x005A97F0</c> and independently matches
/// garbles the x87 plumbing) — ported per the PDB argument ORDER /// ACE <c>Physics.Common.Position.CylinderDistance</c>. It uses full 3D center
/// (own radius/height, own position, target radius/height, target /// distance, both radii, both heights, and returns signed overlap.
/// position) with the standard cylinder-distance shape: horizontal
/// (planar) distance minus the sum of the two radii (clamped at 0), since
/// that is the only shape consistent with "edge-to-edge" and with
/// <c>distance_to_object</c>'s ctor default of 0.6 (melee range from
/// surface to surface, not center to center).
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class MoveToMathCylinderDistanceTests public sealed class MoveToMathCylinderDistanceTests
@ -42,14 +37,14 @@ public sealed class MoveToMathCylinderDistanceTests
} }
[Fact] [Fact]
public void TwoCylinders_Overlapping_ClampsAtZero_NoNegativeDistance() public void TwoCylinders_Overlapping_ReturnsSignedThreeDimensionalOverlap()
{ {
// centers 1 unit apart, radii 5 and 5 → would be -9, clamps to 0 // radial gap = 1 - 10 = -9; vertical gap = 0 - 2 = -2.
float d = MoveToMath.CylinderDistance( float d = MoveToMath.CylinderDistance(
ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero, ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero,
targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f)); targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f));
Assert.Equal(0f, d, 3); Assert.Equal(-MathF.Sqrt(85f), d, 3);
} }
[Fact] [Fact]
@ -63,29 +58,33 @@ public sealed class MoveToMathCylinderDistanceTests
} }
[Fact] [Fact]
public void TwoCylinders_IgnoresVerticalSeparation_PlanarOnly() public void TwoCylinders_VerticallyAndRadiallySeparated_CombinesBothGaps()
{ {
// Same X/Y, large Z separation — cylinder_distance in retail's own // full center gap = 5, radial gap = 3; vertical gap = 4 - 2 = 2.
// callers (GetCurrentDistance) is used for horizontal arrival gates; float d = MoveToMath.CylinderDistance(
// the Z axis is height, not part of the radial edge-to-edge gap. ownRadius: 1f, ownHeight: 2f, ownPos: Vector3.Zero,
float d1 = MoveToMath.CylinderDistance( targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(3f, 0f, 4f));
ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 0),
targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, 0f));
float d2 = MoveToMath.CylinderDistance(
ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 50f),
targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, -50f));
Assert.Equal(d1, d2, 3); Assert.Equal(MathF.Sqrt(13f), d, 3);
Assert.Equal(3f, d1, 3); // 5 - 1 - 1
} }
[Fact] [Fact]
public void SamePosition_ZeroDistance_ClampsNotNegative() public void SamePosition_ReturnsSignedOverlap()
{ {
float d = MoveToMath.CylinderDistance( float d = MoveToMath.CylinderDistance(
ownRadius: 0.5f, ownHeight: 2f, ownPos: Vector3.Zero, ownRadius: 0.5f, ownHeight: 2f, ownPos: Vector3.Zero,
targetRadius: 0.5f, targetHeight: 2f, targetPos: Vector3.Zero); targetRadius: 0.5f, targetHeight: 2f, targetPos: Vector3.Zero);
Assert.Equal(0f, d, 3); Assert.Equal(-MathF.Sqrt(5f), d, 3);
}
[Fact]
public void OppositeGapSigns_ReturnsRadialGapVerbatim()
{
float d = MoveToMath.CylinderDistance(
ownRadius: 5f, ownHeight: 1f, ownPos: Vector3.Zero,
targetRadius: 5f, targetHeight: 1f, targetPos: new Vector3(0f, 0f, 3f));
Assert.Equal(-7f, d, 3);
} }
} }

View file

@ -0,0 +1,68 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance coverage for retail
/// <c>ACCWeenieObject::ObjectsInRange @ 0x0058C1A0</c>. The external-container
/// registration passes <c>useRadii=true</c> and <c>ignoreZDelta=false</c> at
/// <c>gmExternalContainerUI::SetGroundObject @ 0x004CBBD0</c>.
/// </summary>
public sealed class ObjectRangeMathTests
{
[Fact]
public void ExternalContainerRange_UsesSurfaceGapRatherThanCenterDistance()
{
var player = new Vector3(7.01f, 98.06f, -0.45f);
var corpse = new Vector3(3.73f, 98.03f, -0.44f);
bool inRange = ObjectRangeMath.ObjectsInRange(
player, firstRadius: 0.48f, firstHeight: 1.835f,
corpse, secondRadius: 0.80f, secondHeight: 0.50f,
range: 2.01,
useRadii: true,
ignoreZDelta: false);
Assert.True(inRange);
}
[Fact]
public void CenterDistanceMode_DoesNotSubtractObjectRadii()
{
bool inRange = ObjectRangeMath.ObjectsInRange(
Vector3.Zero, firstRadius: 10f, firstHeight: 10f,
new Vector3(3f, 0f, 0f), secondRadius: 10f, secondHeight: 10f,
range: 2.0,
useRadii: false,
ignoreZDelta: false);
Assert.False(inRange);
}
[Fact]
public void IgnoreZDelta_UsesXyCenterDistanceAndTakesPrecedenceOverRadii()
{
bool inRange = ObjectRangeMath.ObjectsInRange(
Vector3.Zero, firstRadius: 100f, firstHeight: 100f,
new Vector3(3f, 4f, 1000f), secondRadius: 100f, secondHeight: 100f,
range: 5.0,
useRadii: true,
ignoreZDelta: true);
Assert.True(inRange);
}
[Fact]
public void RangeBoundary_IsInclusive()
{
bool inRange = ObjectRangeMath.ObjectsInRange(
Vector3.Zero, firstRadius: 0f, firstHeight: 0f,
new Vector3(3f, 4f, 0f), secondRadius: 0f, secondHeight: 0f,
range: 5.0,
useRadii: false,
ignoreZDelta: false);
Assert.True(inRange);
}
}