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

@ -29,6 +29,12 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class RetailChaseCamera : ICamera
{
private const float RetailDefaultBack = 2.5f;
private const float RetailDefaultUp = 0.75f;
private const float RetailLookDownBack = 2f;
private const float RetailMapBack = 450f;
private const float RetailFirstPersonForward = 0.18f;
// ICamera surface.
public Vector3 Position { get; private set; }
@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
private bool _lookingDown;
private bool _mapMode;
private bool _inHead;
private bool _savedInHead;
private float _savedDistance;
private float _savedPitch;
private float _savedYawOffset;
private Vector3? _targetDirectionLocal;
private Vector3? _savedTargetDirectionLocal;
public bool IsLookingDown => _lookingDown;
public bool IsMapMode => _mapMode;
public bool IsInHead => _inHead;
/// <summary>
/// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
@ -172,8 +192,13 @@ public sealed class RetailChaseCamera : ICamera
// target supplies the frame heading. Without this local rotation, enabling
// Keep in View snaps the camera behind the target and disables RMB orbit.
float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
(Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
(Vector3 targetEye, Vector3 targetForward) = _inHead
? ComputeInHeadPose(pivotWorld, heading)
: _targetDirectionLocal is { } localDirection
? ComputeTargetDirectionPose(
pivotWorld, heading, Distance, Pitch, localDirection)
: ComputeDesiredPose(
pivotWorld, heading, Distance, Pitch, viewerYawOffset);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary>
public void AdjustDistance(float delta) =>
public void AdjustDistance(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// </summary>
public void AdjustPitch(float delta) =>
public void AdjustPitch(float delta)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
public void SetRetailDefaultView()
{
_lookingDown = false;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = null;
YawOffset = 0f;
PivotHeight = 1.5f;
SetViewerOffset(RetailDefaultBack, RetailDefaultUp);
}
public void SetRetailFirstPersonView()
{
_lookingDown = false;
_mapMode = false;
_inHead = true;
_targetDirectionLocal = null;
YawOffset = 0f;
Distance = RetailFirstPersonForward;
Pitch = 0f;
// Do not spend a transition frame inside the head/neck. Retail's
// SetInHead installs the new viewer offset as one camera preset.
_initialised = false;
}
public void ToggleRetailLookDownView()
{
if (_lookingDown)
{
RestoreLookDownView();
return;
}
SaveLookDownView();
_lookingDown = true;
_mapMode = false;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(RetailLookDownBack, RetailDefaultUp);
}
public void ToggleRetailMapModeView()
{
if (_mapMode)
{
RestoreLookDownView();
return;
}
if (!_lookingDown)
SaveLookDownView();
_lookingDown = true;
_mapMode = true;
_inHead = false;
_targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f);
SetViewerOffset(RetailMapBack, RetailDefaultUp);
}
private void SaveLookDownView()
{
_savedDistance = Distance;
_savedPitch = Pitch;
_savedYawOffset = YawOffset;
_savedTargetDirectionLocal = _targetDirectionLocal;
_savedInHead = _inHead;
}
private void RestoreLookDownView()
{
Distance = _savedDistance;
Pitch = _savedPitch;
YawOffset = _savedYawOffset;
_targetDirectionLocal = _savedTargetDirectionLocal;
_inHead = _savedInHead;
_lookingDown = false;
_mapMode = false;
}
private void ExitLookDownForAdjustment()
{
if (_lookingDown)
RestoreLookDownView();
}
private void ExitInHeadForAdjustment()
{
if (!_inHead)
return;
_inHead = false;
Distance = DistanceMin;
}
private void SetViewerOffset(float back, float up)
{
Distance = MathF.Sqrt(back * back + up * up);
Pitch = MathF.Atan2(up, back);
}
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera
return (eye, forward);
}
/// <summary>
/// Retail <c>CameraSet::SetInHead @ 0x00458CE0</c>: the target direction
/// is local +Y and the viewer offset is local +Y * 0.18. It is not a
/// negative chase boom looking back toward the player's neck.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose(
Vector3 pivotWorld,
Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
return (pivotWorld + forward * RetailFirstPersonForward, forward);
}
/// <summary>
/// Transform both retail <c>viewer_offset</c> and <c>target_direction</c>
/// through the target frame. LookDown/MapMode do not merely point a
/// horizontally-positioned camera toward the ground: the downward target
/// direction pitches the frame whose -Y/+Z offset places the viewer. For
/// MapMode's (0,-450,0.75) offset this puts the viewer high above and only
/// slightly behind the character, matching CameraSet::SetMapMode.
/// </summary>
internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose(
Vector3 pivotWorld,
Vector3 heading,
float distance,
float pitch,
Vector3 targetDirectionLocal)
{
var (frameForward, frameRight, frameUp) = BuildBasis(heading);
Vector3 targetForward = Vector3.Normalize(
frameForward * targetDirectionLocal.Y
- frameRight * targetDirectionLocal.X
+ frameUp * targetDirectionLocal.Z);
var (_, _, targetUp) = BuildBasis(targetForward);
float back = distance * MathF.Cos(pitch);
float up = distance * MathF.Sin(pitch);
Vector3 eye = pivotWorld - targetForward * back + targetUp * up;
return (eye, targetForward);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it