diff --git a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md
index ddc7cc48..1e5bcb37 100644
--- a/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md
+++ b/docs/research/2026-07-17-retail-external-container-looting-pseudocode.md
@@ -15,6 +15,11 @@ Named Sept-2013 retail symbols:
- `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0`
- `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0`
- `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::OnVisibilityChanged @ 0x004CBF50`
- `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0`
@@ -128,9 +133,19 @@ ExternalPanel.SetGroundObject(containerId):
if containerId == 0:
hide panel
else:
- register range watch using root UseRadius
+ register range watch(root, root.UseRadius,
+ useRadii = true, ignoreZDelta = false)
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():
if currentExternalRoot == 0:
return
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 0eac938f..6682fd92 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -13529,6 +13529,7 @@ public sealed class GameWindow : IDisposable
private bool IsWithinExternalContainerUseRange(uint targetGuid)
{
if (_playerController is null
+ || !_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)
|| !_visibleEntitiesByServerGuid.TryGetValue(targetGuid, out var entity))
{
// 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)
|| spawn.UseRadius is not > 0f)
return true;
- float useRadius = spawn.UseRadius.Value;
- float dx = entity.Position.X - _playerController.Position.X;
- float dy = entity.Position.Y - _playerController.Position.Y;
- return dx * dx + dy * dy <= useRadius * useRadius;
+
+ // gmExternalContainerUI::SetGroundObject @ 0x004CBBD0 registers the
+ // root's UseRadius with useRadii=true and ignoreZDelta=false.
+ // 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)
diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
index e7365edb..c170f96c 100644
--- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs
+++ b/src/AcDream.Core/Physics/Motion/MoveToMath.cs
@@ -203,31 +203,30 @@ public static class MoveToMath
/// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
/// distance; position moves use plain center (Euclidean) distance
/// instead (not ported here — Vector3.Distance already covers
- /// it). BN garbles the x87 plumbing in the raw, so the exact
- /// radius-combination arithmetic is not directly visible; ported per
- /// the PDB argument ORDER (own radius/height/position, target
- /// radius/height/position) with the standard cylinder-distance shape:
- /// planar (X/Y) center distance minus the sum of both radii, clamped
- /// at zero (overlapping cylinders report 0, never negative). Height is
- /// accepted for signature parity with the retail call (own/target
- /// height feed the caller's contact-plane logic elsewhere) but does
- /// NOT participate in this edge-to-edge planar computation — matching
- /// retail's use of cylinder_distance purely for the horizontal arrival
- /// gate.
+ /// it). Binary Ninja garbles the x87 result plumbing, but the complete
+ /// branch structure is visible in
+ /// Position::cylinder_distance @ 0x005A97F0 and is independently
+ /// preserved by ACE Physics.Common.Position.CylinderDistance:
+ /// subtract both radii from the full 3D center distance, compute the
+ /// signed vertical gap between the two cylinders, then combine gaps only
+ /// when their signs agree. Overlap is therefore a negative distance
+ /// rather than a value clamped to zero.
///
public static float CylinderDistance(
float ownRadius, float ownHeight, Vector3 ownPos,
float targetRadius, float targetHeight, Vector3 targetPos)
{
- _ = ownHeight;
- _ = targetHeight;
+ float radialGap = Vector3.Distance(ownPos, targetPos)
+ - (ownRadius + targetRadius);
+ float verticalGap = ownPos.Z <= targetPos.Z
+ ? targetPos.Z - (ownPos.Z + ownHeight)
+ : ownPos.Z - (targetPos.Z + targetHeight);
- float dx = targetPos.X - ownPos.X;
- float dy = targetPos.Y - ownPos.Y;
- float centerDist = MathF.Sqrt(dx * dx + dy * dy);
-
- float edgeDist = centerDist - ownRadius - targetRadius;
- return edgeDist > 0f ? edgeDist : 0f;
+ if (verticalGap > 0f && radialGap > 0f)
+ return MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap);
+ if (verticalGap < 0f && radialGap < 0f)
+ return -MathF.Sqrt(verticalGap * verticalGap + radialGap * radialGap);
+ return radialGap;
}
///
diff --git a/src/AcDream.Core/Physics/ObjectRangeMath.cs b/src/AcDream.Core/Physics/ObjectRangeMath.cs
new file mode 100644
index 00000000..9071eaff
--- /dev/null
+++ b/src/AcDream.Core/Physics/ObjectRangeMath.cs
@@ -0,0 +1,53 @@
+using System.Numerics;
+using AcDream.Core.Physics.Motion;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Retail object-range predicate used by CPlayerSystem range handlers.
+/// Port of ACCWeenieObject::ObjectsInRange @ 0x0058C1A0.
+///
+public static class ObjectRangeMath
+{
+ ///
+ /// Tests two live physics objects against a caller-supplied range. Retail
+ /// gives precedence; otherwise
+ /// selects cylinder-gap distance instead of
+ /// ordinary center distance.
+ ///
+ 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;
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs
index eb21d692..c9bbe264 100644
--- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs
@@ -17,15 +17,10 @@ namespace AcDream.Core.Tests.Physics.Motion;
/// available, not re-ported here).
///
///
-/// The retail signature's exact combination math for radius/height beyond
-/// "edge-to-edge, own+target cylinders" is not spelled out in the raw (BN
-/// garbles the x87 plumbing) — ported per the PDB argument ORDER
-/// (own radius/height, own position, target radius/height, target
-/// 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
-/// distance_to_object's ctor default of 0.6 (melee range from
-/// surface to surface, not center to center).
+/// The exact branch structure is visible at named-retail
+/// Position::cylinder_distance @ 0x005A97F0 and independently matches
+/// ACE Physics.Common.Position.CylinderDistance. It uses full 3D center
+/// distance, both radii, both heights, and returns signed overlap.
///
///
public sealed class MoveToMathCylinderDistanceTests
@@ -42,14 +37,14 @@ public sealed class MoveToMathCylinderDistanceTests
}
[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(
ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero,
targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f));
- Assert.Equal(0f, d, 3);
+ Assert.Equal(-MathF.Sqrt(85f), d, 3);
}
[Fact]
@@ -63,29 +58,33 @@ public sealed class MoveToMathCylinderDistanceTests
}
[Fact]
- public void TwoCylinders_IgnoresVerticalSeparation_PlanarOnly()
+ public void TwoCylinders_VerticallyAndRadiallySeparated_CombinesBothGaps()
{
- // Same X/Y, large Z separation — cylinder_distance in retail's own
- // callers (GetCurrentDistance) is used for horizontal arrival gates;
- // the Z axis is height, not part of the radial edge-to-edge gap.
- float d1 = MoveToMath.CylinderDistance(
- 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));
+ // full center gap = 5, radial gap = 3; vertical gap = 4 - 2 = 2.
+ float d = MoveToMath.CylinderDistance(
+ ownRadius: 1f, ownHeight: 2f, ownPos: Vector3.Zero,
+ targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(3f, 0f, 4f));
- Assert.Equal(d1, d2, 3);
- Assert.Equal(3f, d1, 3); // 5 - 1 - 1
+ Assert.Equal(MathF.Sqrt(13f), d, 3);
}
[Fact]
- public void SamePosition_ZeroDistance_ClampsNotNegative()
+ public void SamePosition_ReturnsSignedOverlap()
{
float d = MoveToMath.CylinderDistance(
ownRadius: 0.5f, ownHeight: 2f, ownPos: 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);
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs b/tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs
new file mode 100644
index 00000000..8f0cd6a9
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs
@@ -0,0 +1,68 @@
+using System.Numerics;
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance coverage for retail
+/// ACCWeenieObject::ObjectsInRange @ 0x0058C1A0. The external-container
+/// registration passes useRadii=true and ignoreZDelta=false at
+/// gmExternalContainerUI::SetGroundObject @ 0x004CBBD0.
+///
+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);
+ }
+}