From 0f82a08f0ad8ba682746808e8da2567c45ac143e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 20:32:34 +0200 Subject: [PATCH] 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 --- ...l-external-container-looting-pseudocode.md | 17 ++++- src/AcDream.App/Rendering/GameWindow.cs | 23 +++++-- src/AcDream.Core/Physics/Motion/MoveToMath.cs | 37 +++++----- src/AcDream.Core/Physics/ObjectRangeMath.cs | 53 +++++++++++++++ .../Motion/MoveToMathCylinderDistanceTests.cs | 51 +++++++------- .../Physics/ObjectRangeMathTests.cs | 68 +++++++++++++++++++ 6 files changed, 199 insertions(+), 50 deletions(-) create mode 100644 src/AcDream.Core/Physics/ObjectRangeMath.cs create mode 100644 tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs 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); + } +}