fix(client): restore retail interaction parity
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:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue