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

@ -35,7 +35,8 @@ internal sealed record LiveInventorySessionBindings(
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
ExternalContainerState? ExternalContainers);
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
internal sealed record LiveCharacterSessionBindings(
CombatState Combat,
@ -157,6 +158,7 @@ internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
onUseDone: inventory.OnUseDone,
onAppraisal: inventory.OnAppraisal,
itemMana: inventory.ItemMana,
onConfirmationRequest: character.OnConfirmationRequest,
onConfirmationDone: character.OnConfirmationDone,

View file

@ -155,7 +155,7 @@ internal sealed class LiveSessionRuntimeFactory
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
TeleportTransit = _world.Teleport.ResetSession,
SessionDialogs = () => _ui.RetailUi?.ResetSessionDialogs(),
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
@ -260,7 +260,14 @@ internal sealed class LiveSessionRuntimeFactory
_domain.ItemMana,
OnDesiredComponents: components =>
_player.DesiredComponents.Items = components,
ExternalContainers: _domain.ExternalContainers);
ExternalContainers: _domain.ExternalContainers,
OnAppraisal: appraisal =>
{
if (_ui.RetailUi is { } retailUi)
retailUi.HandleAppraisal(appraisal);
else
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
});
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)

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)

View file

@ -0,0 +1,712 @@
using System.Globalization;
using System.Numerics;
using System.Text;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmExaminationUI</c>. The controller owns the
/// appraisal response/subview lifecycle; the imported LayoutDesc owns all
/// chrome, geometry, fonts, and scrollbars.
/// </summary>
public sealed class AppraisalUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100006Bu;
public const uint RootId = 0x100005F2u;
public const uint CloseId = 0x100005F3u;
public const uint TitleId = 0x1000012Du;
public const uint ItemPanelId = 0x1000012Eu;
public const uint ItemTextId = 0x1000013Cu;
public const uint ItemScrollbarId = 0x1000013Du;
public const uint InscriptionTextId = 0x1000013Eu;
public const uint SignatureTextId = 0x1000013Fu;
public const uint InscriptionScrollbarId = 0x1000046Eu;
public const uint CreaturePanelId = 0x10000140u;
public const uint SpellPanelId = 0x10000153u;
private const uint TemplateStringProperty = 5u;
private const uint CharacterMarkerIntProperty = 0x105u;
private const uint DisplayedNameStringProperty = 0x34u;
private const double CreatureRefreshSeconds = 0.75;
private readonly ImportedLayout _layout;
private readonly ClientObjectTable _objects;
private readonly ItemInteractionController _interaction;
private readonly CombatState _combat;
private readonly Spellbook _spellbook;
private readonly Action _show;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
private readonly UiElement? _spellPanel;
private readonly UiText _title;
private readonly UiText _itemText;
private readonly UiText? _inscriptionText;
private readonly UiField? _inscriptionField;
private readonly UiText? _signature;
private readonly UiButton? _close;
private AppraisalView _activeView;
private uint _itemObjectId;
private uint _creatureObjectId;
private uint _characterObjectId;
private string _titleValue = string.Empty;
private string _itemReport = string.Empty;
private string _inscriptionValue = string.Empty;
private string _signatureValue = string.Empty;
private double _refreshElapsed;
private bool _disposed;
private AppraisalUiController(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Action show,
Action close,
UiElement itemPanel,
UiElement creaturePanel,
UiText title,
UiText itemText)
{
_layout = layout;
_objects = objects;
_interaction = interaction;
_combat = combat;
_spellbook = spellbook;
_show = show;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
_spellPanel = layout.FindElement(SpellPanelId);
_title = title;
_itemText = itemText;
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_title.LinesProvider = () =>
[new UiText.Line(_titleValue, _title.DefaultColor)];
ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport);
if (_inscriptionText is not null)
ConfigureScrollableText(
_inscriptionText,
InscriptionScrollbarId,
() => _inscriptionValue);
if (_inscriptionField is not null)
{
_inscriptionField.SetText(string.Empty);
// Retail conditionally enables this field only after its ownership
// and inscription-permission checks. That write transaction remains
// tracked by AP-110; keep the imported field display-only until the
// matching SetInscriptionEditableState/submit path is present.
_inscriptionField.Enabled = false;
}
if (_signature is not null)
_signature.LinesProvider = () =>
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
if (_close is not null)
_close.OnClick = close;
SetActiveView(AppraisalView.Item);
}
public AppraisalView ActiveView => _activeView;
public uint CurrentObjectId => _interaction.CurrentAppraisalId;
public static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Action show,
Action close)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(show);
ArgumentNullException.ThrowIfNull(close);
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|| layout.FindElement(TitleId) is not UiText title
|| layout.FindElement(ItemTextId) is not UiText itemText)
return null;
return new AppraisalUiController(
layout,
objects,
interaction,
combat,
spellbook,
show,
close,
itemPanel,
creaturePanel,
title,
itemText);
}
/// <summary>
/// Applies the response ordering and first-response visibility rule from
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
/// </summary>
public bool Apply(AppraiseInfoParser.Parsed appraisal)
{
ItemInteractionController.AppraisalResponseAcceptance acceptance =
_interaction.AcceptAppraisalResponse(appraisal.Guid);
if (!acceptance.Accepted)
return false;
ClientObject? obj = _objects.Get(appraisal.Guid);
if (obj is null)
return false;
_titleValue = BuildTitle(obj, appraisal.Properties);
AppraisalView view = SelectView(appraisal);
bool newlySelected = view switch
{
AppraisalView.Item => _itemObjectId != appraisal.Guid,
AppraisalView.Creature => _creatureObjectId != appraisal.Guid,
AppraisalView.Character => _characterObjectId != appraisal.Guid,
_ => false,
};
switch (view)
{
case AppraisalView.Item:
_itemObjectId = appraisal.Guid;
ApplyItem(obj, appraisal, newlySelected);
break;
case AppraisalView.Creature:
_creatureObjectId = appraisal.Guid;
ApplyCreature(obj, appraisal, character: false, newlySelected);
break;
case AppraisalView.Character:
_characterObjectId = appraisal.Guid;
ApplyCreature(obj, appraisal, character: true, newlySelected);
break;
}
SetActiveView(view);
_refreshElapsed = 0;
if (acceptance.FirstResponse)
_show();
return true;
}
public void Tick(double deltaSeconds)
{
if (!_layout.Root.Visible
|| _activeView is not (AppraisalView.Creature or AppraisalView.Character)
|| _combat.CurrentMode == CombatMode.NonCombat
|| CurrentObjectId == 0)
{
_refreshElapsed = 0;
return;
}
if (!double.IsFinite(deltaSeconds) || deltaSeconds <= 0)
return;
_refreshElapsed += deltaSeconds;
if (_refreshElapsed < CreatureRefreshSeconds)
return;
_refreshElapsed %= CreatureRefreshSeconds;
_interaction.RefreshCurrentAppraisal();
}
public void ResetSession()
{
_titleValue = string.Empty;
_itemReport = string.Empty;
_inscriptionValue = string.Empty;
_inscriptionField?.SetText(string.Empty);
_signatureValue = string.Empty;
_itemObjectId = 0;
_creatureObjectId = 0;
_characterObjectId = 0;
_refreshElapsed = 0;
SetActiveView(AppraisalView.Item);
ClearCreatureText();
}
private void ApplyItem(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool newlySelected)
{
_itemReport = AppraisalTextFormatter.BuildItem(
obj,
appraisal,
ResolveSpellName);
_inscriptionValue = GetString(appraisal.Properties, 7u);
_inscriptionField?.SetText(_inscriptionValue);
string scribe = GetString(appraisal.Properties, 8u);
_signatureValue = string.IsNullOrWhiteSpace(scribe)
? string.Empty
: $"Inscribed by {scribe}";
if (newlySelected)
{
_itemText.Scroll.SetScrollY(0);
_inscriptionText?.Scroll.SetScrollY(0);
}
}
private void ApplyCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool character,
bool newlySelected)
{
ClearCreatureText();
PropertyBundle p = appraisal.Properties;
SetText(0x1000014Au, character
? GetString(p, 4u)
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u)));
SetText(0x1000014Bu, GetString(p, 2u));
SetText(0x1000014Cu, GetString(p, 14u));
string report = AppraisalTextFormatter.BuildCreature(
obj,
appraisal,
character);
SetText(0x1000014Eu, report, scrollable: true);
if (character)
{
SetText(0x10000150u, GetString(p, 4u));
SetText(0x10000151u, GetString(p, 47u));
SetText(0x10000152u, GetString(p, 11u));
}
SetText(0x1000053Au, appraisal.Success
? string.Empty
: "Assessment incomplete");
if (newlySelected)
ResetCreatureScroll();
}
private void ConfigureScrollableText(
UiText text,
uint scrollbarId,
Func<string> value)
{
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = true;
text.ClickThrough = false;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value());
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
}
private void SetText(uint elementId, string value, bool scrollable = false)
{
if (_layout.FindElement(elementId) is not UiText text)
return;
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = scrollable;
text.ClickThrough = !scrollable;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
if (scrollable)
{
UiScrollbar? scrollbar = Descendants(text.Parent)
.OfType<UiScrollbar>()
.FirstOrDefault(candidate => candidate.Visible);
if (scrollbar is not null)
scrollbar.Model = text.Scroll;
}
}
private void ClearCreatureText()
{
foreach (uint id in new uint[]
{
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu,
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
})
if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>();
}
private void ResetCreatureScroll()
{
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
text.Scroll.SetScrollY(0);
}
private void SetActiveView(AppraisalView view)
{
_activeView = view;
_itemPanel.Visible = view == AppraisalView.Item;
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
if (_spellPanel is not null)
_spellPanel.Visible = false;
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
if (creatureInfo is not null)
creatureInfo.Visible = view == AppraisalView.Creature;
if (characterInfo is not null)
characterInfo.Visible = view == AppraisalView.Character;
}
private string? ResolveSpellName(uint spellId)
=> _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata)
? metadata.Name
: null;
private static AppraisalView SelectView(AppraiseInfoParser.Parsed appraisal)
{
if (appraisal.CreatureProfile is null)
return AppraisalView.Item;
return appraisal.Properties.Strings.ContainsKey(TemplateStringProperty)
|| appraisal.Properties.Ints.ContainsKey(CharacterMarkerIntProperty)
? AppraisalView.Character
: AppraisalView.Creature;
}
private static string BuildTitle(ClientObject obj, PropertyBundle properties)
{
string name = GetString(properties, DisplayedNameStringProperty);
if (string.IsNullOrWhiteSpace(name))
name = obj.GetAppropriateName();
return obj.StackSize > 1
? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}"
: name;
}
private static int GetInt(PropertyBundle properties, uint id)
=> properties.Ints.TryGetValue(id, out int value) ? value : 0;
private static string GetString(PropertyBundle properties, uint id)
=> properties.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
private static IEnumerable<UiElement> Descendants(UiElement? root)
{
if (root is null)
yield break;
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
}
public void OnShown()
{
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_close is not null)
_close.OnClick = null;
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll)))
scrollbar.Model = null;
}
}
public enum AppraisalView
{
Item,
Creature,
Character,
}
/// <summary>Pure retail-ordered text projection for the authored examination fields.</summary>
public static class AppraisalTextFormatter
{
private const uint Level = 25u;
private const uint ArmorLevel = 28u;
private const uint Damage = 44u;
private const uint DamageType = 45u;
private const uint WeaponTime = 49u;
private const uint Structure = 92u;
private const uint MaxStructure = 91u;
private const uint Workmanship = 105u;
private const uint CurrentMana = 107u;
private const uint MaximumMana = 108u;
private const uint Difficulty = 109u;
private const uint WieldRequirement = 158u;
private const uint WieldSkill = 159u;
private const uint WieldDifficulty = 160u;
private const uint RareId = 17u;
public static string BuildItem(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, string?> resolveSpellName)
{
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(resolveSpellName);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Value", obj.Value != 0 ? obj.Value : GetInt(p, 19u), "N0");
Append(text, "Burden", obj.Burden != 0 ? obj.Burden : GetInt(p, 5u), "N0");
int workmanship = GetInt(p, Workmanship);
if (workmanship != 0 || obj.Workmanship != 0)
AppendLine(text, "Workmanship",
(workmanship != 0 ? workmanship : obj.Workmanship)
.ToString("0.##", CultureInfo.CurrentCulture));
if (appraisal.WeaponProfile is { } weapon)
{
AppendLine(text, "Damage",
$"{weapon.Damage.ToString(CultureInfo.CurrentCulture)} {DamageTypeName(weapon.DamageType)}");
AppendLine(text, "Speed", weapon.WeaponTime.ToString(CultureInfo.CurrentCulture));
AppendPercent(text, "Damage Variance", weapon.DamageVariance);
AppendPercent(text, "Attack Bonus", weapon.WeaponOffense - 1d);
}
else
{
Append(text, "Damage", GetInt(p, Damage));
int damageType = GetInt(p, DamageType);
if (damageType != 0)
AppendLine(text, "Damage Type", DamageTypeName((uint)damageType));
Append(text, "Speed", GetInt(p, WeaponTime));
}
if (appraisal.ArmorProfile is { } armor)
{
Append(text, "Armor Level", GetInt(p, ArmorLevel));
AppendProtection(text, "Slashing", armor.SlashingProtection);
AppendProtection(text, "Piercing", armor.PiercingProtection);
AppendProtection(text, "Bludgeoning", armor.BludgeoningProtection);
AppendProtection(text, "Fire", armor.FireProtection);
AppendProtection(text, "Cold", armor.ColdProtection);
AppendProtection(text, "Acid", armor.AcidProtection);
AppendProtection(text, "Electric", armor.LightningProtection);
AppendProtection(text, "Nether", armor.NetherProtection);
}
Append(text, "Level", GetInt(p, Level));
Append(text, "Difficulty", GetInt(p, Difficulty));
int currentMana = GetInt(p, CurrentMana);
int maxMana = GetInt(p, MaximumMana);
if (currentMana != 0 || maxMana != 0)
AppendLine(text, "Mana",
$"{currentMana.ToString("N0", CultureInfo.CurrentCulture)} / {maxMana.ToString("N0", CultureInfo.CurrentCulture)}");
int currentUses = obj.Structure != 0 ? obj.Structure : GetInt(p, Structure);
int maxUses = obj.MaxStructure != 0 ? obj.MaxStructure : GetInt(p, MaxStructure);
if (currentUses != 0 || maxUses != 0)
AppendLine(text, "Uses",
maxUses > 0
? $"{currentUses.ToString(CultureInfo.CurrentCulture)} / {maxUses.ToString(CultureInfo.CurrentCulture)}"
: currentUses.ToString(CultureInfo.CurrentCulture));
if (GetInt(p, WieldRequirement) != 0)
AppendLine(
text,
"Wield Requirement",
$"{SkillName(GetInt(p, WieldSkill))} {GetInt(p, WieldDifficulty).ToString(CultureInfo.CurrentCulture)}");
if (appraisal.HookProfile is { } hook)
{
if ((hook.Flags & 0x1u) != 0)
AppendFlag(text, "Inscribable");
if ((hook.Flags & 0x2u) != 0)
AppendFlag(text, "Healer");
if ((hook.Flags & 0x4u) != 0)
AppendFlag(text, "Food");
if ((hook.Flags & 0x8u) != 0)
AppendFlag(text, "Lockpick");
}
foreach (uint rawSpellId in appraisal.SpellBook)
{
uint spellId = rawSpellId & 0x7FFF_FFFFu;
AppendFlag(text, resolveSpellName(spellId) ?? $"Spell {spellId}");
}
if (GetInt(p, RareId) != 0)
AppendFlag(text, "Rare Item");
string use = GetString(p, 14u);
if (!string.IsNullOrWhiteSpace(use))
AppendParagraph(text, use);
string shortDescription = GetString(p, 15u);
if (!string.IsNullOrWhiteSpace(shortDescription))
AppendParagraph(text, shortDescription);
string longDescription = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(longDescription)
&& !string.Equals(longDescription, shortDescription, StringComparison.Ordinal))
AppendParagraph(text, longDescription);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string BuildCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool character)
{
ArgumentNullException.ThrowIfNull(obj);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Level", GetInt(p, Level));
if (appraisal.CreatureProfile is { } creature)
{
AppendLine(text, "Health",
$"{creature.Health.ToString("N0", CultureInfo.CurrentCulture)} / {creature.HealthMax.ToString("N0", CultureInfo.CurrentCulture)}");
AppendOptional(text, "Stamina", creature.Stamina, creature.StaminaMax);
AppendOptional(text, "Mana", creature.Mana, creature.ManaMax);
AppendOptional(text, "Strength", creature.Strength);
AppendOptional(text, "Endurance", creature.Endurance);
AppendOptional(text, "Coordination", creature.Coordination);
AppendOptional(text, "Quickness", creature.Quickness);
AppendOptional(text, "Focus", creature.Focus);
AppendOptional(text, "Self", creature.Self);
}
string title = GetString(p, 2u);
if (character && !string.IsNullOrWhiteSpace(title))
AppendLine(text, "Title", title);
string description = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(description))
AppendParagraph(text, description);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string CreatureTypeName(int creatureType)
=> creatureType == 0
? string.Empty
: $"Creature type {creatureType.ToString(CultureInfo.CurrentCulture)}";
private static void AppendOptional(
StringBuilder text,
string label,
uint? current,
uint? maximum = null)
{
if (current is null)
return;
AppendLine(
text,
label,
maximum is { } max
? $"{current.Value.ToString("N0", CultureInfo.CurrentCulture)} / {max.ToString("N0", CultureInfo.CurrentCulture)}"
: current.Value.ToString("N0", CultureInfo.CurrentCulture));
}
private static void Append(
StringBuilder text,
string label,
int value,
string? format = null)
{
if (value == 0)
return;
AppendLine(
text,
label,
value.ToString(format ?? "0", CultureInfo.CurrentCulture));
}
private static void AppendPercent(StringBuilder text, string label, double value)
{
if (Math.Abs(value) < 0.000001)
return;
AppendLine(text, label, value.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture));
}
private static void AppendProtection(StringBuilder text, string label, float value)
{
if (Math.Abs(value - 1f) < 0.0001f)
return;
AppendLine(text, label, value.ToString("0.00x", CultureInfo.CurrentCulture));
}
private static void AppendFlag(StringBuilder text, string value)
=> AppendParagraph(text, value);
private static void AppendLine(StringBuilder text, string label, string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (text.Length != 0)
text.Append('\n');
text.Append(label).Append(": ").Append(value);
}
private static void AppendParagraph(StringBuilder text, string value)
{
if (text.Length != 0)
text.Append("\n\n");
text.Append(value);
}
private static int GetInt(PropertyBundle p, uint id)
=> p.Ints.TryGetValue(id, out int value) ? value : 0;
private static string GetString(PropertyBundle p, uint id)
=> p.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
private static string DamageTypeName(uint type) => type switch
{
1u => "Slashing",
2u => "Piercing",
4u => "Bludgeoning",
8u => "Cold",
16u => "Fire",
32u => "Acid",
64u => "Electric",
1024u => "Nether",
_ => $"Type {type.ToString(CultureInfo.CurrentCulture)}",
};
private static string SkillName(int skill) => skill switch
{
1 => "Axe",
3 => "Dagger",
6 => "Mace",
9 => "Spear",
10 => "Staff",
12 => "Sword",
13 => "Thrown Weapons",
14 => "Unarmed Combat",
15 => "Arcane Lore",
18 => "Magic Defense",
19 => "Mana Conversion",
21 => "Item Tinkering",
22 => "Assess Person",
23 => "Deception",
24 => "Healing",
27 => "Lockpick",
30 => "Creature Enchantment",
31 => "Item Enchantment",
32 => "Life Magic",
34 => "War Magic",
40 => "Light Weapons",
41 => "Heavy Weapons",
42 => "Finesse Weapons",
43 => "Missile Weapons",
44 => "Shield",
45 => "Dual Wield",
46 => "Recklessness",
47 => "Sneak Attack",
48 => "Dirty Fighting",
54 => "Void Magic",
55 => "Summoning",
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
};
}

View file

@ -16,6 +16,12 @@ public static class RetailPanelCatalog
public const uint Character = 11u;
public const uint Magic = 13u;
public const uint Vitae = 15u;
/// <summary>
/// Private retained-host identity for retail's floating examination UI.
/// The original opens it by notice rather than a gmPanelUI numeric child,
/// so this value is deliberately outside the DAT panel-id range.
/// </summary>
public const uint Examination = 0x8000_0001u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
@ -28,6 +34,7 @@ public static class RetailPanelCatalog
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae),
(Examination, WindowNames.Examination),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =

View file

@ -234,6 +234,7 @@ public sealed class RetailUiRuntime : IDisposable
MountToolbar();
MountCombat();
MountSpellbook();
MountAppraisal();
MountEffects();
MountIndicatorDetailPanels();
MountIndicators();
@ -293,6 +294,7 @@ public sealed class RetailUiRuntime : IDisposable
public CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public AppraisalUiController? AppraisalController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; }
@ -340,6 +342,7 @@ public sealed class RetailUiRuntime : IDisposable
FpsController?.Tick();
_vividTargetIndicator?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
@ -391,6 +394,11 @@ public sealed class RetailUiRuntime : IDisposable
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
public bool HandleAppraisal(AppraiseInfoParser.Parsed appraisal)
=> AppraisalController is { } controller
? controller.Apply(appraisal)
: ItemInteraction.AcceptAppraisalResponse(appraisal.Guid).Accepted;
public uint ShowConfirmation(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
@ -419,6 +427,13 @@ public sealed class RetailUiRuntime : IDisposable
}
}
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
}
public void UpdateCursor(IEnumerable<IMouse> mice)
{
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
@ -938,6 +953,67 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountAppraisal()
{
ImportedLayout? layout = Import(
AppraisalUiController.LayoutId,
AppraisalUiController.RootId);
if (layout is null)
{
Console.WriteLine(
"[M4] examination: LayoutDesc 0x2100006B root 0x100005F2 not found.");
return;
}
AppraisalUiController? controller = AppraisalUiController.Bind(
layout,
_bindings.Inventory.Objects,
_bindings.Inventory.ItemInteraction,
_bindings.Combat.State,
_bindings.Magic.Spellbook,
show: () => _panelUi.SetPanelVisibility(
RetailPanelCatalog.Examination,
visible: true),
close: () => CloseWindow(WindowNames.Examination));
if (controller is null)
{
Console.WriteLine(
"[M4] examination: required authored controls are missing.");
return;
}
AppraisalController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Examination,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
ResizeX = true,
ResizeY = true,
MinWidth = 310f,
MinHeight = 400f,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Examination,
WindowNames.Examination,
handle);
Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);

View file

@ -21,4 +21,5 @@ public static class WindowNames
public const string LinkStatus = "link-status";
public const string MiniGame = "mini-game";
public const string Vitae = "vitae";
public const string Examination = "examination";
}