feat(ui): port retail appraisal panel

Preserve retail's one-pending-appraisal busy lifetime, parse the complete gated response, and mount the authored examination layout in the shared main-panel host. Keep known 3D preview and inscription-write gaps explicit in AP-110.
This commit is contained in:
Erik 2026-07-23 11:34:08 +02:00
parent 6b1ae4fb76
commit 643cdfe66e
24 changed files with 17132 additions and 40 deletions

View file

@ -110,6 +110,8 @@ public sealed class ItemInteractionController : IDisposable
private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2;
private int _busyCount;
private uint _awaitingAppraisalId;
private uint _currentAppraisalId;
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
@ -222,6 +224,7 @@ public sealed class ItemInteractionController : IDisposable
public InteractionState InteractionState => _interactionState;
public int BusyCount => _busyCount;
public uint CurrentAppraisalId => _currentAppraisalId;
public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
@ -451,7 +454,7 @@ public sealed class ItemInteractionController : IDisposable
ClearTargetMode();
accepted = targetGuid != 0 && _sendExamine is not null;
if (accepted)
_sendExamine!(targetGuid);
RequestAppraisal(targetGuid);
break;
case InteractionModeKind.UseItemOnTarget:
accepted = AcquireTarget(targetGuid);
@ -492,7 +495,61 @@ public sealed class ItemInteractionController : IDisposable
return _interactionState.EnterExamine();
if (_sendExamine is null)
return false;
_sendExamine(selectedObjectId);
RequestAppraisal(selectedObjectId);
return true;
}
/// <summary>
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
/// One shared UI-busy reference covers the currently pending appraisal,
/// even when another examine request replaces its GUID before a response.
/// </summary>
private void RequestAppraisal(uint objectId)
{
if (objectId == 0 || _sendExamine is null)
return;
if (_awaitingAppraisalId == 0)
IncrementBusyCount();
_awaitingAppraisalId = objectId;
_sendExamine(objectId);
}
/// <summary>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
/// </summary>
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
{
if (objectId == 0
|| (objectId != _awaitingAppraisalId
&& objectId != _currentAppraisalId))
return default;
bool firstResponse = objectId == _awaitingAppraisalId;
if (firstResponse)
{
_awaitingAppraisalId = 0;
if (_busyCount > 0)
_busyCount--;
_currentAppraisalId = objectId;
StateChanged?.Invoke();
}
return new AppraisalResponseAcceptance(
Accepted: true,
FirstResponse: firstResponse);
}
/// <summary>
/// Retail combat-time creature/player refresh sends CM_Item directly and
/// therefore does not acquire a second UI-busy reference.
/// </summary>
public bool RefreshCurrentAppraisal()
{
if (_currentAppraisalId == 0 || _sendExamine is null)
return false;
_sendExamine(_currentAppraisalId);
return true;
}
@ -1240,6 +1297,8 @@ public sealed class ItemInteractionController : IDisposable
_pendingBackpackPlacement = null;
_useReservationGeneration++;
_busyCount = 0;
_awaitingAppraisalId = 0;
_currentAppraisalId = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
@ -1258,6 +1317,10 @@ public sealed class ItemInteractionController : IDisposable
failures);
}
public readonly record struct AppraisalResponseAcceptance(
bool Accepted,
bool FirstResponse);
private static void DispatchAll(Action? listeners, List<Exception> failures)
{
if (listeners is null)