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:
parent
e6dd8bf6fa
commit
0f82a08f0a
6 changed files with 199 additions and 50 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
53
src/AcDream.Core/Physics/ObjectRangeMath.cs
Normal file
53
src/AcDream.Core/Physics/ObjectRangeMath.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs
Normal file
68
tests/AcDream.Core.Tests/Physics/ObjectRangeMathTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue