acdream/src/AcDream.App/Interaction/WorldSelectionQuery.cs
Erik 02b735ba4a
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 17:17:04 +02:00

582 lines
25 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Selection;
using AcDream.App.UI.Layout;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.App.Interaction;
internal readonly record struct SelectionCameraSnapshot(
Matrix4x4 View,
Matrix4x4 Projection,
Vector2 Viewport);
internal readonly record struct PlayerInteractionPose(uint CellId, Vector3 Position);
internal readonly record struct WorldInteractionTarget(
uint ServerGuid,
uint LocalEntityId,
WorldEntity Entity);
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
internal readonly record struct InteractionApproach(
WorldInteractionTarget Target,
PlayerInteractionPose Player,
float UseRadius,
bool IsCloseRange,
bool CanCharge,
float TargetRadius,
float TargetHeight);
internal interface IWorldSelectionQuery
{
uint? PickAtCursor(bool includeSelf);
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
void BeginLightingPulse(uint serverGuid);
bool TryCaptureIdentity(uint serverGuid, out uint localEntityId);
bool IsCurrent(uint serverGuid, uint localEntityId);
string Describe(uint serverGuid);
bool IsCreature(uint serverGuid);
bool IsHostileMonster(uint serverGuid);
bool IsAttackableTarget(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster();
bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid);
bool IsWieldedByPlayer(uint serverGuid);
bool IsWieldedPositionState(uint serverGuid);
bool TryGetApproach(uint serverGuid, out InteractionApproach approach);
Vector3? GetCombatCameraTargetPoint(uint serverGuid);
}
internal interface IRetainedUiSelectionQuery
{
bool ShouldShowHealth(uint serverGuid);
VividTargetInfo? ResolveVividTargetInfo(uint serverGuid);
bool IsWithinExternalContainerUseRange(uint serverGuid);
}
internal interface ISelectionViewPlaneSource
{
AcDream.App.Rendering.ICamera ApplyViewPlane(
AcDream.App.Rendering.ICamera camera);
}
/// <summary>
/// Read-only owner for retail world selection and interaction classification.
/// It consumes the canonical live-entity runtime but never mutates selection,
/// movement, inventory, or network state.
/// </summary>
internal sealed class WorldSelectionQuery
: IWorldSelectionQuery,
IRetainedUiSelectionQuery
{
private const uint StuckObjectFlag = 0x0004u;
/// <summary>
/// ACE's own fallback when a target authors no wire <c>UseRadius</c> at
/// all (<c>WorldObject_Use.cs:50</c>, <c>useRadius ?? 0.6f</c>) — see
/// <see cref="GetUseRadius"/>.
/// </summary>
private const float DefaultUseRadius = 0.6f;
private const float AceCanChargeDistance = 7.5f;
private const uint SmallItemMask =
(uint)(ItemType.MeleeWeapon
| ItemType.Armor
| ItemType.Clothing
| ItemType.Jewelry
| ItemType.Food
| ItemType.Money
| ItemType.Misc
| ItemType.MissileWeapon
| ItemType.Container
| ItemType.Gem
| ItemType.SpellComponents
| ItemType.Writable
| ItemType.Key
| ItemType.Caster);
private readonly LiveEntityRuntime _liveEntities;
private readonly ClientObjectTable _objects;
private readonly RetailSelectionScene _selectionScene;
private readonly Func<uint> _playerGuid;
private readonly Func<SelectionCameraSnapshot> _camera;
private readonly Func<Vector2> _cursor;
private readonly Func<PlayerInteractionPose?> _playerPose;
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
private readonly Func<uint, Matrix4x4?> _childRootPose;
public WorldSelectionQuery(
LiveEntityRuntime liveEntities,
ClientObjectTable objects,
RetailSelectionScene selectionScene,
Func<uint> playerGuid,
Func<SelectionCameraSnapshot> camera,
Func<Vector2> cursor,
Func<PlayerInteractionPose?> playerPose,
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
Func<uint, Matrix4x4?> childRootPose)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_selectionScene = selectionScene ?? throw new ArgumentNullException(nameof(selectionScene));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
_playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose));
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
}
public uint? PickAtCursor(bool includeSelf)
{
Vector2 cursor = _cursor();
return PickAt(cursor.X, cursor.Y, includeSelf);
}
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
{
SelectionCameraSnapshot camera = _camera();
RetailSelectionHit? hit = _selectionScene.Pick(
mouseX,
mouseY,
camera.Viewport,
camera.View,
camera.Projection,
includeSelf ? 0u : _playerGuid());
// Render::GfxObjUnderSelectionRay @ 0x0054C740 records the winning
// part's own physobj id, so an equipped child resolves to the CHILD's
// GUID. There is no parent fallback in the retail path: a hit whose
// identity is no longer current is simply discarded.
return hit is { } found
&& _liveEntities.TryGetPickEligibleRecord(
found.ServerGuid,
found.LocalEntityId,
out _)
? found.ServerGuid
: null;
}
public void BeginLightingPulse(uint serverGuid)
{
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
_selectionScene.BeginLightingPulse(target.ServerGuid, target.LocalEntityId);
}
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
if (TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
{
localEntityId = target.LocalEntityId;
return true;
}
localEntityId = 0u;
return false;
}
public bool TryGetInteractionTarget(
uint serverGuid,
out WorldInteractionTarget target)
{
if (_liveEntities.TryGetPickEligibleRecord(
serverGuid,
out LiveEntityRecord record)
&& record.WorldEntity is { } entity)
{
target = new WorldInteractionTarget(serverGuid, entity.Id, entity);
return true;
}
target = default;
return false;
}
public bool IsCurrent(WorldInteractionTarget target)
=> IsCurrent(target.ServerGuid, target.LocalEntityId);
public bool IsCurrent(uint serverGuid, uint localEntityId)
=> _liveEntities.TryGetPickEligibleRecord(serverGuid, localEntityId, out _);
public ItemType GetItemType(uint serverGuid)
=> _objects.Get(serverGuid)?.Type ?? ItemType.None;
public string Describe(uint serverGuid)
{
string? name = _objects.Get(serverGuid)?.Name;
return string.IsNullOrWhiteSpace(name) ? $"0x{serverGuid:X8}" : name;
}
public bool IsCreature(uint serverGuid)
{
if (serverGuid == _playerGuid()
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
{
return false;
}
if (_liveEntities.TryGetAnimationRuntime(target.LocalEntityId, out var animation)
&& animation.CurrentMotion == MotionCommand.Dead)
{
return false;
}
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
}
/// <summary>
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
/// non-player, non-pet, attackable monsters. Backs
/// <see cref="FindClosestHostileMonster"/> only — never explicit
/// selection, and never the combat camera (retail gates camera tracking
/// on <c>ObjectIsAttackable</c>, not this narrower policy — see
/// <see cref="GetCombatCameraTargetPoint"/>).
/// </summary>
public bool IsHostileMonster(uint serverGuid)
=> IsCreature(serverGuid)
&& CombatTargetPolicy.IsHostileMonster(
_playerGuid(),
_objects.Get(_playerGuid()),
_objects.Get(serverGuid));
/// <summary>
/// Explicit-target admission for a user-issued attack (#298), and the
/// combat camera's tracking gate. Retail
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> and
/// <c>UpdateTargetTracking @ 0x0056A950</c> both gate unconditionally on
/// <c>ObjectIsAttackable @ 0x0056A600</c>, with no player exclusion — a
/// compatible-PK player is a valid attack target and a valid camera
/// target. This is deliberately a different, wider predicate than
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
/// AUTOMATIC ACQUISITION ONLY per register row IA-19 — IA-19 does not
/// reach explicit selection or the camera (a presentation gate on an
/// already-chosen target, not an acquisition path).
/// </summary>
public bool IsAttackableTarget(uint serverGuid)
=> IsCreature(serverGuid)
&& SelectedObjectHealthPolicy.ObjectIsAttackable(
_playerGuid(),
_objects.Get(_playerGuid()),
serverGuid,
_objects.Get(serverGuid));
public bool ShouldShowHealth(uint serverGuid)
=> SelectedObjectHealthPolicy.ShouldQueryHealth(
_playerGuid(),
_objects.Get(_playerGuid()),
_objects.Get(serverGuid));
public ClosestCombatTarget? FindClosestHostileMonster()
{
if (!_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player))
return null;
ClosestCombatTarget? best = null;
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
{
uint guid = record.ServerGuid;
WorldEntity entity = record.WorldEntity!;
if (!IsHostileMonster(guid))
continue;
float distanceSquared = Vector3.DistanceSquared(entity.Position, player.Position);
if (best is null || distanceSquared < best.Value.DistanceSquared)
best = new ClosestCombatTarget(guid, distanceSquared);
}
return best;
}
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
/// on <c>ObjectIsAttackable @ 0x0056A600</c> — the SAME wide predicate as
/// <c>ExecuteAttack</c>, not the narrower automatic-acquisition policy.
/// The camera performs no acquisition of its own; it only tracks a
/// target the player already selected, so routing it through
/// <see cref="IsAttackableTarget"/> does not touch register row IA-19
/// (which scopes itself to automatic acquisition).
/// </summary>
public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
=> IsAttackableTarget(serverGuid)
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
? target.Entity.Position
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)
: null;
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
{
uint playerGuid = _playerGuid();
ClientObject? target = _objects.Get(serverGuid);
// VividTargetIndicator::SetSelected @ 0x004F5CE0 deliberately keeps
// ACCWeenieObject::selectedID intact while suppressing its own marker
// for self, player-owned objects, and objects whose current state is
// IN_CONTAINER. ContainerId is our authoritative placement projection
// for that retail state. PositionState.WIELDED is a distinct retail
// state (acclient.h:6802), so a remote character's wielded item keeps
// its marker while the local player's own wielded item is suppressed
// by the shared IsOwnedByObject test.
if (serverGuid == playerGuid
|| target is null
|| _objects.IsOwnedByObject(serverGuid, playerGuid)
|| target.ContainerId != 0u
|| !(_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _)
|| _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _))
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
{
return null;
}
uint pwdBits = _liveEntities.TryGetSnapshot(serverGuid, out var spawn)
? spawn.ObjectDescriptionFlags ?? 0u
: 0u;
return new VividTargetInfo(center, radius, (uint)GetItemType(serverGuid), pwdBits);
}
/// <summary>
/// SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the object's OWN
/// <c>m_position</c> (<c>Render::positionPush(3, &amp;obj-&gt;m_position)</c>)
/// and delegates to CPhysicsObj::GetSelectionSphere @ 0x0050EA40, which
/// scales the Setup sphere by that object's own part-array scale
/// (CPartArray::GetSelectionSphere @ 0x00518B80). A 0.1-unit fallback is
/// used when Setup authored no sphere.
/// </summary>
/// <remarks>
/// For an equipped child the object's own m_position is the frame
/// CPhysicsObj::UpdateChild @ 0x00512D50 composes each tick as
/// <c>Frame::combine(parent part frame, holding frame)</c>. acdream stores
/// the PARENT's root in the child projection's Position/Rotation
/// (EquippedChildRenderController.ApplyParentWorldPose) because the child's
/// MeshRefs are parent-relative, so the exact equivalent anchor is the
/// composed child root already published per frame to
/// EntityEffectPoseRegistry (EquippedChildRenderController.PublishChildPose).
/// There is no parent fallback: with no published child root the child has
/// no live composed frame this tick and has no sphere.
/// </remarks>
public bool TryGetSelectionSphere(
uint serverGuid,
out Vector3 worldCenter,
out float worldRadius)
{
worldCenter = default;
worldRadius = 0f;
if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity))
return false;
bool attached =
_liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _);
Matrix4x4 childRoot = Matrix4x4.Identity;
if (attached)
{
if (_childRootPose(entity.Id) is not { } published)
return false;
childRoot = published;
}
worldCenter = attached ? childRoot.Translation : entity.Position;
worldRadius = 0.1f;
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|| spawn.SetupTableId is not uint setupId
|| _selectionSphere(setupId) is not { } sphere
|| sphere.Radius <= 1e-4f)
{
return true;
}
// An attached projection never carries its wire ObjScale on the
// WorldEntity — the child's scale is baked into its part transforms by
// EquippedChildAttachment.TryComposePoseInto, leaving RootLocal rigid.
// The spawn record is therefore the authoritative part-array scale,
// exactly as EquippedChildRenderController.TryRealize reads it.
float scale = attached
? (spawn.ObjScale is { } childScale && childScale > 0f ? childScale : 1f)
: (entity.Scale > 0f ? entity.Scale : 1f);
Vector3 localCenter = sphere.Origin * scale;
worldCenter = attached
? Vector3.Transform(localCenter, childRoot)
: entity.Position + Vector3.Transform(localCenter, entity.Rotation);
worldRadius = sphere.Radius * scale;
return true;
}
/// <summary>ItemUses::IsUseable @ retail 0x004FCCC0 call family.</summary>
public bool IsUseable(uint serverGuid)
{
if (_liveEntities.TryGetSnapshot(serverGuid, out var spawn))
return ItemUseability.IsUseable(
spawn.Useability ?? ItemUseability.Undef);
return false;
}
/// <summary>
/// Retail's sr_Use branch of
/// <c>UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @
/// 0x004E5AD0</c> compares the found object's <c>pwd._wielderID</c> against
/// <c>SmartBox::player_id</c> at <c>0x004E5BE9</c> and calls
/// <c>ItemHolder::UseObject</c> only when they differ. Selection and the
/// click lighting pulse still happen for the player's own wielded item.
/// </summary>
public bool IsWieldedByPlayer(uint serverGuid)
{
uint playerGuid = _playerGuid();
return playerGuid != 0u
&& _objects.Get(serverGuid) is { } item
&& item.WielderId == playerGuid;
}
/// <summary>
/// <c>ACCWeenieObject::DeterminePositionState @ 0x0058BE70</c> resolves
/// <c>PositionState.WIELDED</c> (acclient.h:6802) as a zero
/// <c>pwd._containerID</c> with a nonzero <c>pwd._location</c>;
/// <c>IN_CONTAINER</c> wins when both are set.
/// <c>ClientObject.CurrentlyEquippedLocation</c> is acdream's projection of
/// <c>pwd._location</c> (the <c>CurrentWieldedLocation</c> PublicWeenieDesc
/// field, acclient.h:37175).
/// </summary>
/// <remarks>
/// Two retail gates read this state. The pickup legality arm at
/// <c>0x005872B7</c> pairs it with ownership, and
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> records the
/// <c>IR_PICK_UP</c> world request only for <c>IN_3D_VIEW</c>, treating
/// <c>WIELDED</c> as a plain <c>IR_PUT_IN_CONTAINER</c> transfer.
/// </remarks>
public bool IsWieldedPositionState(uint serverGuid)
=> _objects.Get(serverGuid) is { } item
&& item.ContainerId == 0u
&& item.CurrentlyEquippedLocation != EquipMask.None;
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>
public bool IsPickupable(uint serverGuid)
{
if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|| ((spawn.ObjectDescriptionFlags ?? 0u) & StuckObjectFlag) != 0u)
{
return false;
}
return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u;
}
/// <summary>
/// Builds the world-pickup approach. Only retail's
/// <c>PositionState.IN_3D_VIEW</c> objects have one:
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> records
/// <c>IR_PICK_UP</c> for that state alone, and an attached child is by
/// construction not an independent 3-D object — its bookkeeping
/// <c>WorldEntity.Position</c> carries the PARENT's composed root
/// (<c>EquippedChildRenderController.ApplyParentWorldPose</c>), not the
/// child frame <c>CPhysicsObj::UpdateChild @ 0x00512D50</c> composes.
/// Refusing the attached case keeps an approach from ever anchoring on a
/// wielder; wielded items reach their container transfer without one.
/// </summary>
public bool TryGetApproach(
uint serverGuid,
out InteractionApproach approach)
{
if (_playerPose() is not { } player
|| _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _)
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target))
{
approach = default;
return false;
}
float useRadius = GetUseRadius(serverGuid);
float dx = target.Entity.Position.X - player.Position.X;
float dy = target.Entity.Position.Y - player.Position.Y;
float distanceSquared = dx * dx + dy * dy;
(float radius, float height) = _setupCylinder(serverGuid, target.Entity);
approach = new InteractionApproach(
target,
player,
useRadius,
distanceSquared <= useRadius * useRadius,
distanceSquared >= AceCanChargeDistance * AceCanChargeDistance,
radius,
height);
return true;
}
public bool IsWithinExternalContainerUseRange(uint serverGuid)
{
if (_playerPose() is not { } playerPose
|| !_liveEntities.TryGetWorldEntity(_playerGuid(), out WorldEntity player)
|| !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
|| !_liveEntities.TryGetSnapshot(serverGuid, out var spawn)
|| spawn.UseRadius is not > 0f)
{
// The server remains authoritative while render projection is absent.
return true;
}
var playerCylinder = _setupCylinder(_playerGuid(), player);
var targetCylinder = _setupCylinder(serverGuid, target.Entity);
return ObjectRangeMath.ObjectsInRange(
playerPose.Position,
playerCylinder.Radius,
playerCylinder.Height,
target.Entity.Position,
targetCylinder.Radius,
targetCylinder.Height,
spawn.UseRadius.Value,
useRadii: true,
ignoreZDelta: false);
}
/// <summary>
/// R2 gate-finding fix (2026-08-08, grand-gate walk-to-vendor
/// regression): retail reads the TARGET's own wire-authored
/// <c>PublicWeenieDesc::_useRadius</c> directly for every
/// range/approach purpose — <c>CPlayerSystem::RegisterObjectRangeHandler</c>
/// (<c>pc:195159</c>/<c>203677</c>/<c>210429</c>; <c>203677</c> is
/// <c>gmVendorUI::OpenVendor</c>'s own range-handler registration,
/// reading <c>eax-&gt;pwd._useRadius</c> for the VENDOR target itself,
/// the exact NPC kind this bug was found on). ACE's server-side
/// acceptance test (<c>WorldObject_Use.cs:47-55</c>,
/// <c>IsWithinUseRadiusOf</c>) reads the SAME wire field:
/// <c>useRadius ?? 0.6f</c> — no item-type special-casing at all.
/// </summary>
/// <remarks>
/// <b>What this replaces.</b> The prior implementation ignored the wire
/// field entirely and guessed a flat radius from the target's item
/// type/flags (3m for ANY <see cref="ItemType.Creature"/>, 2m for a
/// "large object" flag combination, 0.6m otherwise) — an uncited,
/// retail-incorrect heuristic. For a typical vendor NPC (Creature-typed,
/// authoring a much tighter UseRadius than 3m in practice) this made
/// the CLIENT's own local "arrived" test (<c>MoveToManager</c>'s
/// <see cref="AcDream.Core.Physics.Motion.MoveToManager.GetCurrentDistance"/>
/// cylinder-distance arrival check, gated on
/// <see cref="AcDream.Core.Physics.Motion.MovementParameters.DistanceToObject"/>
/// = this method's return value) satisfied well outside ACE's real
/// acceptance zone. The player's walk stopped — and the AP-170/G3
/// arrival-gated Use dispatched — several meters before the player was
/// ever within ACE's own poll-based <c>WithinUseRadius</c> check
/// (<c>Player_Move.cs</c>'s <c>CreateMoveToChain</c>), so
/// <c>ApproachVendor</c> never arrived: the exact "Use lost silently"
/// defect AP-170 closed, reproduced from a different angle (a too-loose
/// LOCAL arrival threshold racing ACE's tighter real one, not a missing
/// arrival gate). The vendor's cosmetic greeting the user observed on
/// approach is a SEPARATE, distance-only proximity emote independent of
/// this Use-radius gate — its firing does not imply the player was ever
/// within ACE's real UseRadius.
/// </remarks>
private float GetUseRadius(uint serverGuid)
{
bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn);
bool fromWire = haveSpawn && spawn.UseRadius is > 0f;
float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} "
+ $"source={(fromWire ? "wire" : "fallback-0.6")}");
}
return radius;
}
}