fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -24,6 +24,8 @@ internal sealed class SelectionInteractionController
private readonly IPlayerInteractionMovementSink _movement;
private readonly PlayerApproachCompletionState _approachCompletions;
private readonly Action<string>? _toast;
private readonly Func<uint, bool>? _splitStack;
private readonly Func<IEnumerable<uint>> _fellowshipMembers;
public SelectionInteractionController(
SelectionState selection,
@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController
IRuntimeInteractionTransport transport,
IPlayerInteractionMovementSink movement,
Action<string>? toast = null,
PlayerApproachCompletionState? approachCompletions = null)
PlayerApproachCompletionState? approachCompletions = null,
Func<uint, bool>? splitStack = null,
Func<IEnumerable<uint>>? fellowshipMembers = null)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_query = query ?? throw new ArgumentNullException(nameof(query));
@ -43,14 +47,96 @@ internal sealed class SelectionInteractionController
_toast = toast;
_approachCompletions = approachCompletions
?? new PlayerApproachCompletionState();
_splitStack = splitStack;
_fellowshipMembers = fellowshipMembers ?? (() => Array.Empty<uint>());
}
public bool HandleInputAction(InputAction action)
{
switch (action)
{
case InputAction.SelectionSelf:
SelectSelf();
return true;
case InputAction.SelectionPlaceInInventory:
PlaceSelectionInBackpack(mainPack: false);
return true;
case InputAction.SelectionPlaceInMainPack:
PlaceSelectionInBackpack(mainPack: true);
return true;
case InputAction.SelectionSplitStack:
if (_selection.SelectedObjectId is { } stack)
_splitStack?.Invoke(stack);
return true;
case InputAction.SelectionClosestCompassItem:
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Closest);
return true;
case InputAction.SelectionPreviousCompassItem:
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Previous);
return true;
case InputAction.SelectionNextCompassItem:
SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Next);
return true;
case InputAction.SelectionClosestItem:
SelectRetailTarget(
RetailSelectionKind.Item,
RetailSelectionDirection.Closest,
excludeOwnedByPlayer: true);
return true;
case InputAction.SelectionPreviousItem:
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Previous);
return true;
case InputAction.SelectionNextItem:
SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Next);
return true;
case InputAction.SelectionClosestMonster:
SelectClosestCombatTarget(showToast: true);
SelectRetailTarget(
RetailSelectionKind.Monster,
RetailSelectionDirection.Closest,
showToast: true);
return true;
case InputAction.SelectionPreviousMonster:
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Previous);
return true;
case InputAction.SelectionNextMonster:
SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Next);
return true;
case InputAction.SelectionLastAttacker:
if (_query.FindLastAttacker() is { } attacker)
_selection.Select(attacker, SelectionChangeSource.Keyboard);
return true;
case InputAction.SelectionClosestPlayer:
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Closest);
return true;
case InputAction.SelectionPreviousPlayer:
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Previous);
return true;
case InputAction.SelectionNextPlayer:
SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Next);
return true;
case InputAction.SelectionPreviousFellow:
SelectFellow(previous: true);
return true;
case InputAction.SelectionNextFellow:
SelectFellow(previous: false);
return true;
case InputAction.SelectionClosestUnopenedCorpse:
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Closest);
return true;
case InputAction.SelectionNextUnopenedCorpse:
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Next);
return true;
case InputAction.SelectionUseClosestUnopenedCorpse:
SelectAndUseCorpse(RetailSelectionDirection.Closest);
return true;
case InputAction.SelectionUseNextUnopenedCorpse:
SelectAndUseCorpse(RetailSelectionDirection.Next);
return true;
case InputAction.SelectionGiveToTarget:
GiveSelectionToPreviousTarget();
return true;
case InputAction.SelectionDrop:
DropSelection();
return true;
case InputAction.SelectionPreviousSelection:
_selection.SelectPrevious();
@ -87,11 +173,109 @@ internal sealed class SelectionInteractionController
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
_items.CancelTargetMode();
return true;
case InputAction.EscapeKey when _selection.SelectedObjectId is not null:
// ClientUISystem::OnAction @0x00564C8E: Escape willingly
// loses the current target before it reaches the Gameplay
// Options fallback at 0x00564CBF.
_selection.Clear(SelectionChangeSource.Keyboard);
return true;
default:
return false;
}
}
private void SelectSelf()
{
uint playerGuid = _query.PlayerGuid;
if (playerGuid == 0u)
return;
if (_items.OfferPrimaryClick(playerGuid) is not ItemPrimaryClickResult.NotActive)
return;
_selection.Select(playerGuid, SelectionChangeSource.Keyboard);
}
private void PlaceSelectionInBackpack(bool mainPack)
{
if (_selection.SelectedObjectId is { } selected)
_items.PlaceWorldItemInBackpack(selected, mainPack);
}
private void SelectRetailTarget(
RetailSelectionKind kind,
RetailSelectionDirection direction,
bool excludeOwnedByPlayer = false,
bool showToast = false)
{
uint? anchor = _selection.SelectedObjectId ?? _selection.PreviousObjectId;
uint? target = _query.FindSelectionTarget(
kind,
direction,
anchor,
excludeOwnedByPlayer);
if (target is { } guid)
{
_selection.Select(guid, SelectionChangeSource.Keyboard);
if (showToast)
_toast?.Invoke(_query.Describe(guid));
}
}
private void SelectAndUseCorpse(RetailSelectionDirection direction)
{
SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, direction);
if (_selection.SelectedObjectId is { } corpse)
EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Use,
corpse,
requireLiveEntity: false);
}
private void SelectFellow(bool previous)
{
uint[] fellows = _fellowshipMembers()
.Where(static guid => guid != 0u)
.Distinct()
.ToArray();
if (fellows.Length == 0)
return;
int current = _selection.SelectedObjectId is { } selected
? Array.IndexOf(fellows, selected)
: -1;
int next = previous
? (current > 0 ? current - 1 : fellows.Length - 1)
: (current >= 0 && current + 1 < fellows.Length ? current + 1 : 0);
_selection.Select(fellows[next], SelectionChangeSource.Keyboard);
}
private void GiveSelectionToPreviousTarget()
{
if (_selection.SelectedObjectId is not { } selected
|| _selection.PreviousObjectId is not { } target
|| selected == target
|| !_query.IsCreature(target))
{
_toast?.Invoke(
"You must select a creature or a character to give that to.\n");
return;
}
if (_items.PlaceSelectedIn3D(selected, target))
_selection.Select(target, SelectionChangeSource.Keyboard);
}
private void DropSelection()
{
if (_selection.SelectedObjectId is not { } selected)
return;
if (!_items.IsOwnedByPlayer(selected))
{
_toast?.Invoke("You must pick that up first");
return;
}
_items.PlaceSelectedIn3D(selected, targetGuid: 0u);
}
public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf);

View file

@ -6,7 +6,9 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
using AcDream.Core.World;
namespace AcDream.App.Interaction;
@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget(
internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared);
internal enum RetailSelectionKind
{
Item,
CompassItem,
Monster,
Player,
UnopenedCorpse,
}
internal enum RetailSelectionDirection
{
Closest,
Previous,
Next,
}
internal readonly record struct InteractionApproach(
WorldInteractionTarget Target,
PlayerInteractionPose Player,
@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach(
internal interface IWorldSelectionQuery
{
uint PlayerGuid => 0u;
uint? PickAtCursor(bool includeSelf);
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
void BeginLightingPulse(uint serverGuid);
@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery
bool IsHostileMonster(uint serverGuid);
bool IsAttackableTarget(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster();
uint? FindSelectionTarget(
RetailSelectionKind kind,
RetailSelectionDirection direction,
uint? anchor,
bool excludeOwnedByPlayer = false) =>
kind == RetailSelectionKind.Monster
&& direction == RetailSelectionDirection.Closest
? FindClosestHostileMonster()?.ServerGuid
: null;
uint? FindLastAttacker() => null;
bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid);
bool IsWieldedByPlayer(uint serverGuid);
@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery
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;
private readonly Func<uint, bool> _hasOpenedCorpse;
private readonly Func<CombatMode> _combatMode;
private readonly Func<uint, bool> _isFellow;
public WorldSelectionQuery(
LiveEntityRuntime liveEntities,
@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery
Func<PlayerInteractionPose?> playerPose,
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
Func<uint, Matrix4x4?> childRootPose)
Func<uint, Matrix4x4?> childRootPose,
Func<uint, bool>? hasOpenedCorpse = null,
Func<CombatMode>? combatMode = null,
Func<uint, bool>? isFellow = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery
_setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder));
_selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere));
_childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose));
_hasOpenedCorpse = hasOpenedCorpse ?? (_ => false);
_combatMode = combatMode ?? (() => CombatMode.NonCombat);
_isFellow = isFellow ?? (_ => false);
}
public uint PlayerGuid => _playerGuid();
public uint? PickAtCursor(bool includeSelf)
{
Vector2 cursor = _cursor();
@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery
return best;
}
/// <summary>
/// Port of retail <c>CPlayerSystem::SelectNext @ 0x0055F9A0</c>. The
/// ordering scalar is the retail player-space horizontal distance plus
/// <c>1.2 * abs(z)</c>; the object id breaks exact-distance ties through
/// <c>CPlayerSystem::Farther @ 0x0055D830</c>. Previous/next wrap exactly
/// as the paired calls in <c>CPlayerSystem::OnAction @ 0x00561890</c>.
/// </summary>
public uint? FindSelectionTarget(
RetailSelectionKind kind,
RetailSelectionDirection direction,
uint? anchor,
bool excludeOwnedByPlayer = false)
{
uint playerGuid = _playerGuid();
if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player))
return null;
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
? RetailRadar.OutdoorRangeMeters
: RetailRadar.IndoorRangeMeters;
var candidates = new List<(uint Guid, float Order)>();
foreach (LiveEntityRecord record in _liveEntities.VisibleRecords)
{
uint guid = record.ServerGuid;
if (guid == 0u
|| guid == playerGuid
|| record.WorldEntity is not { } entity
|| _objects.Get(guid) is not { } obj
|| (excludeOwnedByPlayer
&& _objects.IsOwnedByObject(guid, playerGuid)))
{
continue;
}
float order = SelectionOrder(player, entity);
if (order > radarRadius
|| !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState))
continue;
candidates.Add((guid, order));
}
if (candidates.Count == 0)
return null;
candidates.Sort(static (left, right) =>
{
int distance = left.Order.CompareTo(right.Order);
return distance != 0 ? distance : left.Guid.CompareTo(right.Guid);
});
if (direction == RetailSelectionDirection.Closest)
return candidates[0].Guid;
(float Order, uint Guid)? anchorKey = null;
if (anchor is { } anchorGuid
&& _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity))
{
anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid);
}
if (anchorKey is null)
{
return direction == RetailSelectionDirection.Previous
? candidates[^1].Guid
: candidates[0].Guid;
}
if (direction == RetailSelectionDirection.Next)
{
foreach ((uint guid, float order) in candidates)
{
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0)
return guid;
}
return candidates[0].Guid;
}
for (int i = candidates.Count - 1; i >= 0; i--)
{
(uint guid, float order) = candidates[i];
if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0)
return guid;
}
return candidates[^1].Guid;
}
public uint? FindLastAttacker()
{
uint playerGuid = _playerGuid();
uint attacker = 0u;
if (_objects.Get(playerGuid) is not { } playerObject
|| !playerObject.Properties.InstanceIds.TryGetValue(
(uint)PropertyInstanceId.CurrentAttacker,
out attacker)
|| attacker == 0u
|| !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)
|| !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target))
{
return null;
}
float radarRadius = IsOutdoorCell(player.VisibilityCellId)
? RetailRadar.OutdoorRangeMeters
: RetailRadar.IndoorRangeMeters;
return SelectionOrder(player, target) <= radarRadius ? attacker : null;
}
private bool MatchesSelectionKind(
RetailSelectionKind kind,
uint guid,
ClientObject obj,
PhysicsStateFlags physicsState)
{
bool showableOnRadar = obj.RadarBehavior is { } behavior
&& RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true);
PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
bool isFellow = _isFellow(guid);
bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile;
bool isSpecialCompassObject = (flags
& (PublicWeenieFlags.Lifestone
| PublicWeenieFlags.Portal
| PublicWeenieFlags.Bindstone)) != 0;
// The common tail of CPlayerSystem::SelectNext rejects every object
// currently inside a container, every cloaked physics object, and a
// PWD carrying the reserved sign bit, independent of selection kind.
if (obj.ContainerId != 0u
|| (physicsState & PhysicsStateFlags.Cloaked) != 0
|| (((uint)flags & 0x8000_0000u) != 0))
return false;
return kind switch
{
RetailSelectionKind.Item =>
obj.RadarBehavior is null or 0
|| isSpecialCompassObject,
RetailSelectionKind.CompassItem =>
(isSpecialCompassObject || showableOnRadar)
&& (!isCombatCompass
|| (IsAttackableTarget(guid)
&& !isFellow
&& (flags & PublicWeenieFlags.Vendor) == 0
&& (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)),
RetailSelectionKind.Monster =>
showableOnRadar
&& IsAttackableTarget(guid)
&& !isFellow
&& (flags & PublicWeenieFlags.Vendor) == 0,
RetailSelectionKind.Player =>
showableOnRadar && (flags & PublicWeenieFlags.Player) != 0,
RetailSelectionKind.UnopenedCorpse =>
(flags & PublicWeenieFlags.Corpse) != 0
&& !_hasOpenedCorpse(guid),
_ => false,
};
}
private static float SelectionOrder(WorldEntity player, WorldEntity target)
{
Vector3 delta = target.Position - player.Position;
Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation));
return MathF.Sqrt(local.X * local.X + local.Y * local.Y)
+ MathF.Abs(local.Z) * 1.2f;
}
private static int CompareSelectionKey(
float leftOrder,
uint leftGuid,
float rightOrder,
uint rightGuid)
{
int order = leftOrder.CompareTo(rightOrder);
return order != 0 ? order : leftGuid.CompareTo(rightGuid);
}
private static bool IsOutdoorCell(uint? cellId)
=> cellId is null || (cellId.Value & 0xFFFFu) < 0x100u;
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>