Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target
examine subview) binds four fixed header lines that acdream was feeding
from the wrong appraisal properties:
- 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes
"<Gender> <Heritage>" via InqGenderHeritageDisplay (@0x005B5AE0) from
Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed
object's creature-type name only when HeritageGroup == 0. Reuses the
Campaign CT gender/heritage tables in CharacterIdentityText (new
GenderHeritageDisplay helper) and the controller's existing
CreatureDisplayNameResolver for the creature-type fallback arm.
- 0x10000151 (Profession/title) got AllegianceName (string 47) — the
title line was never shown at all. Now resolves the CURRENT display
title from Int 261 CharacterTitleId through CharacterTitleResolver
(CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling
back to String 5 Template verbatim when the id is absent or
unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME
CharacterTitleResolver instance the D.2b Character panel already
owns (_bindings.Character.TitleResolver), resolved per call under
DatLock — never captured once at mount time, per the secure-trade
deferred-Func lesson.
- 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown.
Ruling R7: retail reads the LOCAL weenie's PWD bits
(ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the
appraisal payload. Now reads the assessed ClientObject's
PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit
0x02000000 -> "Player Killer Lite", else "Non-Player Killer").
Apply()'s existing bail-out when the object has left the local table
already matches retail's "weenie is gone, leave the line cleared".
- 0x1000053A (AllegianceName) invented a literal "Assessment
incomplete" on failed assess — zero retail provenance, deleted
outright. Retail clears the element first (ClearCreatureText already
does this every ApplyCreature call) then sets String 47
AllegianceName only inside the Int 30 AllegianceRank >= 1 gate.
Scoped to the character branch since retail's CreatureExamineUI
(monsters) never binds this element at all.
None of the four lines are success-gated — ACE sends the int/string
tables even on a failed assess, matching retail's own composition.
First-ever AppraisalView.Character controller test coverage (gap G10):
header composition, title/allegiance fallback and gating, all three PK
variants plus the missing-object clear case, failed-assess rendering
with a repo grep confirming the invented literal is gone, and a
regression pin proving the monster (character: false) path is
untouched.
Register: files AD-114 for the examination preview's animated clone
(mirrors the assessed target's live current pose via
CreatureAppraisalFramePresenter) versus retail's independently
animated private CreatureMode clone (BasicCreatureExamineUI::Init
@0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25
("we animate it, and I like it").
Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1144 lines
43 KiB
C#
1144 lines
43 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using AcDream.App.Spells;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Selection;
|
|
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 InscriptionBackgroundId = 0x10000137u;
|
|
public const uint InscriptionTextId = 0x1000013Eu;
|
|
public const uint SignatureTextId = 0x1000013Fu;
|
|
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
|
public const uint CreaturePanelId = 0x10000140u;
|
|
public const uint CreatureViewportId = 0x10000148u;
|
|
public const uint CreatureStatsListId = 0x10000149u;
|
|
public const uint CreatureExtraListId = 0x10000335u;
|
|
public const uint CreatureDisplayNameId = 0x1000014Eu;
|
|
public const uint CreatureLevelValueId = 0x1000014Cu;
|
|
public const uint SpellPanelId = 0x10000153u;
|
|
public const uint SpellSchoolTextId = 0x1000015Eu;
|
|
public const uint SpellIconId = 0x1000015Fu;
|
|
public const uint SpellManaTextId = 0x10000160u;
|
|
public const uint SpellDurationTextId = 0x10000161u;
|
|
public const uint SpellRangeTextId = 0x10000162u;
|
|
public const uint SpellDisplayTextId = 0x10000163u;
|
|
public const uint SpellFormulaListId = 0x1000032Du;
|
|
|
|
private const uint TemplateStringProperty = 5u;
|
|
/// <summary>
|
|
/// PropertyInt 0x105 (261 decimal) — CharacterTitleId. Doubles as the
|
|
/// Character-subview dispatch marker (<see cref="SelectView"/>) and, as
|
|
/// of AS2, the source id for <see cref="BuildCharacterTitleDisplay"/>'s
|
|
/// current-display-title line (element 0x10000151).
|
|
/// </summary>
|
|
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 SelectionState _selection;
|
|
private readonly CombatState _combat;
|
|
private readonly Spellbook _spellbook;
|
|
private readonly Func<string> _playerName;
|
|
private readonly Action<uint, string> _sendSetInscription;
|
|
private readonly Action<string> _systemMessage;
|
|
private readonly Action _show;
|
|
private readonly Action _closeWindow;
|
|
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 UiDatElement? _inscriptionBackground;
|
|
private readonly UiButton? _close;
|
|
private readonly CreatureAppraisalLayeredList? _creatureStats;
|
|
private readonly CreatureAppraisalLayeredList? _creatureExtra;
|
|
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
|
|
private readonly CreatureDisplayNameResolver _creatureNames;
|
|
private readonly RetailAppraisalNameResolver _itemNames;
|
|
private readonly Func<uint, uint> _resolveSpellIcon;
|
|
private readonly Func<uint, uint> _resolveComponentIcon;
|
|
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
|
|
private readonly Func<MagicSchool, uint> _magicSkill;
|
|
/// <summary>
|
|
/// AS2: element <c>0x10000151</c>'s current-display-title chain
|
|
/// (<c>CharacterTitleResolver.Resolve</c> under the composition layer's
|
|
/// DatLock — resolved PER CALL, never captured once at mount time, per
|
|
/// the secure-trade deferred-Func lesson). Defaults to "never resolves"
|
|
/// so a caller that supplies nothing simply falls back to String 5
|
|
/// Template every time, matching retail's own fallback branch.
|
|
/// </summary>
|
|
private readonly Func<uint, string?> _resolveCharacterTitle;
|
|
private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
|
|
private readonly UiText _spellSchool;
|
|
private readonly UiText _spellMana;
|
|
private readonly UiText _spellDuration;
|
|
private readonly UiText _spellRange;
|
|
private readonly UiText _spellDisplay;
|
|
private readonly UiElement _spellIconHost;
|
|
private readonly UiElement _spellFormulaHost;
|
|
private readonly UiTextureElement _spellIcon;
|
|
private readonly List<UiElement> _spellFormulaCells = [];
|
|
private readonly Dictionary<UiText, UiTextLayoutCache<string>>
|
|
_textLayouts = new();
|
|
private UiTextLayoutCache<ItemAppraisalReport> _itemTextLayout = null!;
|
|
private readonly float _spellFormulaOriginX;
|
|
private readonly float _spellFormulaCellWidth;
|
|
private AppraisalView _activeView;
|
|
private uint _itemObjectId;
|
|
private uint _creatureObjectId;
|
|
private uint _characterObjectId;
|
|
private string _titleValue = string.Empty;
|
|
private ItemAppraisalReport _itemReport = ItemAppraisalReport.Empty;
|
|
private string _inscriptionValue = string.Empty;
|
|
private string _signatureValue = string.Empty;
|
|
private string _scribeName = string.Empty;
|
|
private string _oldInscription = string.Empty;
|
|
private bool _presentationInscribable;
|
|
private uint _spellId;
|
|
private bool _windowVisible;
|
|
private double _refreshElapsed;
|
|
private bool _disposed;
|
|
|
|
private AppraisalUiController(
|
|
ImportedLayout layout,
|
|
ClientObjectTable objects,
|
|
ItemInteractionController interaction,
|
|
SelectionState selection,
|
|
CombatState combat,
|
|
Spellbook spellbook,
|
|
Func<string> playerName,
|
|
Action<uint, string> sendSetInscription,
|
|
Action<string> systemMessage,
|
|
Action show,
|
|
Action close,
|
|
UiElement itemPanel,
|
|
UiElement creaturePanel,
|
|
UiElement spellPanel,
|
|
UiText title,
|
|
UiText itemText,
|
|
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
|
|
CreatureDisplayNameResolver? creatureNames,
|
|
RetailAppraisalNameResolver? itemNames,
|
|
Func<uint, uint>? resolveSpellIcon,
|
|
Func<uint, uint>? resolveComponentIcon,
|
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
|
|
Func<MagicSchool, uint>? magicSkill,
|
|
SpellExamineComponentTemplateFactory? spellComponentTemplates,
|
|
Func<uint, string?>? resolveCharacterTitle)
|
|
{
|
|
_layout = layout;
|
|
_objects = objects;
|
|
_interaction = interaction;
|
|
_selection = selection;
|
|
_combat = combat;
|
|
_spellbook = spellbook;
|
|
_playerName = playerName;
|
|
_sendSetInscription = sendSetInscription;
|
|
_systemMessage = systemMessage;
|
|
_show = show;
|
|
_closeWindow = close;
|
|
_itemPanel = itemPanel;
|
|
_creaturePanel = creaturePanel;
|
|
_spellPanel = spellPanel;
|
|
_title = title;
|
|
_itemText = itemText;
|
|
_creatureRowTemplates = creatureRowTemplates;
|
|
_creatureNames = creatureNames
|
|
?? new CreatureDisplayNameResolver(
|
|
new Dictionary<uint, string>());
|
|
_itemNames = itemNames ?? RetailAppraisalNameResolver.Empty;
|
|
_resolveSpellIcon = resolveSpellIcon ?? (_ => 0u);
|
|
_resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
|
|
_spellComponents = spellComponents ?? (_ => []);
|
|
_magicSkill = magicSkill ?? (_ => 0u);
|
|
_resolveCharacterTitle = resolveCharacterTitle ?? (_ => null);
|
|
_spellComponentTemplates = spellComponentTemplates;
|
|
_spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
|
|
_spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
|
|
_spellDuration = (UiText)layout.FindElement(SpellDurationTextId)!;
|
|
_spellRange = (UiText)layout.FindElement(SpellRangeTextId)!;
|
|
_spellDisplay = (UiText)layout.FindElement(SpellDisplayTextId)!;
|
|
_spellIconHost = layout.FindElement(SpellIconId)!;
|
|
_spellFormulaHost = layout.FindElement(SpellFormulaListId)!;
|
|
_spellFormulaOriginX = _spellFormulaHost.Left;
|
|
_spellFormulaCellWidth = _spellFormulaHost.Width;
|
|
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
|
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
|
_signature = layout.FindElement(SignatureTextId) as UiText;
|
|
_inscriptionBackground =
|
|
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
|
_close = layout.FindElement(CloseId) as UiButton;
|
|
|
|
BindTextSource(
|
|
_title,
|
|
() => _titleValue,
|
|
static (target, value) =>
|
|
[new UiText.Line(value, target.DefaultColor)]);
|
|
// ItemExamineUI::AddItemInfo @ 0x004AC050 appends the freshly
|
|
// rebuilt report from the beginning of the authored text surface.
|
|
// LayoutDesc 0x2100006B carries Bottom here, which is appropriate for
|
|
// static text but not for the generated retail item list.
|
|
_itemText.VerticalJustify = VJustify.Top;
|
|
ConfigureScrollableItemText(_itemText, ItemScrollbarId);
|
|
if (_inscriptionText is not null)
|
|
ConfigureScrollableText(
|
|
_inscriptionText,
|
|
InscriptionScrollbarId,
|
|
() => _inscriptionValue);
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.SetText(string.Empty);
|
|
_inscriptionField.ClearOnSubmit = false;
|
|
_inscriptionField.RecordHistory = false;
|
|
_inscriptionField.Editable = false;
|
|
_inscriptionField.Selectable = false;
|
|
_inscriptionField.OnFocusGained = HandleInscriptionGainingFocus;
|
|
_inscriptionField.OnFocusLost = HandleInscriptionLosingFocus;
|
|
_inscriptionField.OnReadOnlyClick = ReportInscriptionUnavailable;
|
|
if (layout.FindElement(InscriptionScrollbarId) is UiScrollbar scrollbar)
|
|
scrollbar.Model = _inscriptionField.Scroll;
|
|
}
|
|
if (_signature is not null)
|
|
{
|
|
BindTextSource(
|
|
_signature,
|
|
() => _signatureValue,
|
|
static (target, value) =>
|
|
[new UiText.Line(value, target.DefaultColor)]);
|
|
}
|
|
if (_close is not null)
|
|
_close.OnClick = close;
|
|
if (_inscriptionBackground is not null)
|
|
{
|
|
_inscriptionBackground.OnClick = ReportInscriptionUnavailable;
|
|
_inscriptionBackground.ClickThrough = false;
|
|
}
|
|
|
|
if (_spellIconHost is UiDatElement spellIconDat)
|
|
spellIconDat.MediaVisible = false;
|
|
_spellIcon = new UiTextureElement
|
|
{
|
|
Width = _spellIconHost.Width,
|
|
Height = _spellIconHost.Height,
|
|
Anchors = AnchorEdges.Left | AnchorEdges.Top
|
|
| AnchorEdges.Right | AnchorEdges.Bottom,
|
|
};
|
|
_spellIconHost.AddChild(_spellIcon);
|
|
ConfigureSpellText(_spellSchool);
|
|
ConfigureSpellText(_spellMana);
|
|
ConfigureSpellText(_spellDuration);
|
|
ConfigureSpellText(_spellRange);
|
|
ConfigureSpellText(_spellDisplay, wrap: true);
|
|
|
|
if (creatureRowTemplates is not null
|
|
&& layout.FindElement(CreatureStatsListId) is { } statsHost
|
|
&& layout.FindElement(CreatureExtraListId) is { } extraHost
|
|
&& layout.FindElement(CreatureViewportId) is UiViewport viewport)
|
|
{
|
|
_creatureStats = CreatureAppraisalLayeredList.Create(
|
|
_creaturePanel,
|
|
statsHost,
|
|
viewport,
|
|
creatureRowTemplates,
|
|
backgroundZOrder: viewport.ZOrder - 2);
|
|
_creatureExtra = CreatureAppraisalLayeredList.Create(
|
|
_creaturePanel,
|
|
extraHost,
|
|
viewport,
|
|
creatureRowTemplates,
|
|
backgroundZOrder: viewport.ZOrder - 1);
|
|
}
|
|
|
|
_selection.Changed += HandleSelectionChanged;
|
|
_objects.ObjectAdded += HandleSpellComponentObjectChanged;
|
|
_objects.ObjectMoved += HandleSpellComponentObjectMoved;
|
|
_objects.ObjectRemoved += HandleSpellComponentObjectChanged;
|
|
_objects.StackSizeUpdated += HandleSpellComponentObjectChanged;
|
|
_objects.Cleared += HandleSpellComponentObjectsCleared;
|
|
SetActiveView(AppraisalView.Item);
|
|
}
|
|
|
|
public AppraisalView ActiveView => _activeView;
|
|
public uint CurrentObjectId => _interaction.CurrentAppraisalId;
|
|
|
|
public static AppraisalUiController? Bind(
|
|
ImportedLayout layout,
|
|
ClientObjectTable objects,
|
|
ItemInteractionController interaction,
|
|
SelectionState selection,
|
|
CombatState combat,
|
|
Spellbook spellbook,
|
|
Func<string> playerName,
|
|
Action<uint, string> sendSetInscription,
|
|
Action<string> systemMessage,
|
|
Action show,
|
|
Action close,
|
|
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
|
|
CreatureDisplayNameResolver? creatureNames = null,
|
|
RetailAppraisalNameResolver? itemNames = null,
|
|
Func<uint, uint>? resolveSpellIcon = null,
|
|
Func<uint, uint>? resolveComponentIcon = null,
|
|
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
|
|
Func<MagicSchool, uint>? magicSkill = null,
|
|
SpellExamineComponentTemplateFactory? spellComponentTemplates = null,
|
|
Func<uint, string?>? resolveCharacterTitle = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(layout);
|
|
ArgumentNullException.ThrowIfNull(objects);
|
|
ArgumentNullException.ThrowIfNull(interaction);
|
|
ArgumentNullException.ThrowIfNull(selection);
|
|
ArgumentNullException.ThrowIfNull(combat);
|
|
ArgumentNullException.ThrowIfNull(spellbook);
|
|
ArgumentNullException.ThrowIfNull(playerName);
|
|
ArgumentNullException.ThrowIfNull(sendSetInscription);
|
|
ArgumentNullException.ThrowIfNull(systemMessage);
|
|
ArgumentNullException.ThrowIfNull(show);
|
|
ArgumentNullException.ThrowIfNull(close);
|
|
|
|
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|
|
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|
|
|| layout.FindElement(SpellPanelId) is not { } spellPanel
|
|
|| layout.FindElement(TitleId) is not UiText title
|
|
|| layout.FindElement(ItemTextId) is not UiText itemText
|
|
|| layout.FindElement(SpellSchoolTextId) is not UiText
|
|
|| layout.FindElement(SpellManaTextId) is not UiText
|
|
|| layout.FindElement(SpellDurationTextId) is not UiText
|
|
|| layout.FindElement(SpellRangeTextId) is not UiText
|
|
|| layout.FindElement(SpellDisplayTextId) is not UiText
|
|
|| layout.FindElement(SpellIconId) is null
|
|
|| layout.FindElement(SpellFormulaListId) is null)
|
|
return null;
|
|
|
|
return new AppraisalUiController(
|
|
layout,
|
|
objects,
|
|
interaction,
|
|
selection,
|
|
combat,
|
|
spellbook,
|
|
playerName,
|
|
sendSetInscription,
|
|
systemMessage,
|
|
show,
|
|
close,
|
|
itemPanel,
|
|
creaturePanel,
|
|
spellPanel,
|
|
title,
|
|
itemText,
|
|
creatureRowTemplates,
|
|
creatureNames,
|
|
itemNames,
|
|
resolveSpellIcon,
|
|
resolveComponentIcon,
|
|
spellComponents,
|
|
magicSkill,
|
|
spellComponentTemplates,
|
|
resolveCharacterTitle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens retail's local spell-examination subview. Unlike object appraisal,
|
|
/// this performs no network request and never changes the globally selected
|
|
/// object. Source: <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> and
|
|
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>.
|
|
/// </summary>
|
|
public bool ExamineSpell(uint spellId)
|
|
{
|
|
if (spellId == 0u
|
|
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
|
|
return false;
|
|
|
|
_interaction.CancelObjectAppraisalForSpell();
|
|
_spellId = spellId;
|
|
_titleValue = metadata.Name;
|
|
SetSpellText(_spellSchool, $"School: {metadata.School}");
|
|
|
|
string mana = metadata.ManaCost > 0
|
|
? metadata.ManaCost.ToString(CultureInfo.InvariantCulture)
|
|
: "???";
|
|
if (metadata.ManaModifier > 0u)
|
|
mana += $" + {metadata.ManaModifier.ToString(CultureInfo.InvariantCulture)} per target";
|
|
SetSpellText(_spellMana, $"Mana: {mana}");
|
|
|
|
string duration = string.Empty;
|
|
if (metadata.Duration > 0f && metadata.Duration != -1f)
|
|
{
|
|
uint displayed = metadata.Duration >= 60f
|
|
? (uint)(metadata.Duration / 60f)
|
|
: (uint)metadata.Duration;
|
|
duration = metadata.Duration >= 60f
|
|
? $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} min."
|
|
: $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} sec.";
|
|
}
|
|
SetSpellText(_spellDuration, duration);
|
|
|
|
float range = MathF.Min(
|
|
metadata.BaseRangeConstant
|
|
+ metadata.BaseRangeModifier * _magicSkill(metadata.SchoolId),
|
|
75f);
|
|
SetSpellText(
|
|
_spellRange,
|
|
range > 0f
|
|
? string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"Range: {0:F1} yds.",
|
|
range / 0.9144f)
|
|
: string.Empty);
|
|
|
|
IReadOnlyList<SpellExamineComponent> components =
|
|
_spellComponents(spellId);
|
|
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
|
|
_spellDisplay.Scroll.SetScrollY(0);
|
|
_spellIcon.Texture = _resolveSpellIcon(spellId);
|
|
RebuildSpellFormula(components);
|
|
|
|
SetActiveView(AppraisalView.Spell);
|
|
_refreshElapsed = 0;
|
|
_show();
|
|
return true;
|
|
}
|
|
|
|
/// <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 (!_windowVisible
|
|
|| _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 = ItemAppraisalReport.Empty;
|
|
_inscriptionValue = string.Empty;
|
|
_inscriptionField?.SetText(string.Empty);
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.Editable = false;
|
|
_inscriptionField.Selectable = false;
|
|
_inscriptionField.Visible = false;
|
|
_inscriptionField.Scroll.SetScrollY(0);
|
|
}
|
|
_signatureValue = string.Empty;
|
|
if (_signature is not null)
|
|
_signature.Visible = false;
|
|
_scribeName = string.Empty;
|
|
_oldInscription = string.Empty;
|
|
_presentationInscribable = false;
|
|
_itemObjectId = 0;
|
|
_creatureObjectId = 0;
|
|
_characterObjectId = 0;
|
|
_spellId = 0u;
|
|
_refreshElapsed = 0;
|
|
_spellIcon.Texture = 0u;
|
|
SetSpellText(_spellSchool, string.Empty);
|
|
SetSpellText(_spellMana, string.Empty);
|
|
SetSpellText(_spellDuration, string.Empty);
|
|
SetSpellText(_spellRange, string.Empty);
|
|
SetSpellText(_spellDisplay, string.Empty);
|
|
ClearSpellFormula();
|
|
SetActiveView(AppraisalView.Item);
|
|
ClearCreatureText();
|
|
}
|
|
|
|
private void ApplyItem(
|
|
ClientObject obj,
|
|
AppraiseInfoParser.Parsed appraisal,
|
|
bool newlySelected)
|
|
{
|
|
_itemReport = ItemAppraisalTextFormatter.BuildReport(
|
|
obj,
|
|
appraisal,
|
|
ResolveSpell,
|
|
_itemNames);
|
|
SetInscription(obj, appraisal);
|
|
if (newlySelected)
|
|
{
|
|
_itemText.Scroll.SetScrollY(0);
|
|
_inscriptionText?.Scroll.SetScrollY(0);
|
|
_inscriptionField?.Scroll.SetScrollY(0);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>ItemExamineUI::SetInscription @ 0x004AE2F0</c> and
|
|
/// <c>SetInscriptionEditableState @ 0x004AC720</c>.
|
|
/// </summary>
|
|
private void SetInscription(
|
|
ClientObject obj,
|
|
AppraiseInfoParser.Parsed appraisal)
|
|
{
|
|
_oldInscription = string.Empty;
|
|
_scribeName = string.Empty;
|
|
_inscriptionValue = string.Empty;
|
|
_signatureValue = string.Empty;
|
|
|
|
var publicFlags =
|
|
(PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
|
bool publicInscribable =
|
|
(publicFlags & PublicWeenieFlags.Inscribable) != 0;
|
|
_presentationInscribable = obj.IsHook
|
|
? appraisal.HookProfile is { } hook
|
|
&& (hook.Flags & 0x1u) != 0
|
|
: publicInscribable;
|
|
|
|
if (!_presentationInscribable)
|
|
{
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.SetText(string.Empty);
|
|
_inscriptionField.Editable = false;
|
|
_inscriptionField.Selectable = false;
|
|
_inscriptionField.Visible = false;
|
|
}
|
|
if (_inscriptionText is not null)
|
|
_inscriptionText.Visible = false;
|
|
if (_signature is not null)
|
|
_signature.Visible = false;
|
|
if (_inscriptionBackground is not null)
|
|
_inscriptionBackground.ClickThrough = false;
|
|
return;
|
|
}
|
|
|
|
_scribeName = GetString(appraisal.Properties, 8u);
|
|
_oldInscription = GetString(appraisal.Properties, 7u);
|
|
if (string.IsNullOrEmpty(_scribeName))
|
|
{
|
|
_inscriptionValue = "<Inscribe here>";
|
|
}
|
|
else if (!string.IsNullOrEmpty(_oldInscription))
|
|
{
|
|
_inscriptionValue = _oldInscription;
|
|
_signatureValue = $"--{_scribeName}";
|
|
}
|
|
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.SetText(_inscriptionValue);
|
|
_inscriptionField.Visible = true;
|
|
}
|
|
if (_inscriptionText is not null)
|
|
_inscriptionText.Visible = true;
|
|
if (_signature is not null)
|
|
_signature.Visible = true;
|
|
|
|
bool editable = publicInscribable
|
|
&& _interaction.IsOwnedByPlayer(obj.ObjectId)
|
|
&& (string.IsNullOrEmpty(_scribeName)
|
|
|| string.Equals(
|
|
_scribeName,
|
|
_playerName(),
|
|
StringComparison.OrdinalIgnoreCase));
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.Editable = editable;
|
|
_inscriptionField.Selectable = editable;
|
|
}
|
|
if (_inscriptionBackground is not null)
|
|
_inscriptionBackground.ClickThrough = editable;
|
|
}
|
|
|
|
private void HandleInscriptionGainingFocus()
|
|
{
|
|
if (_inscriptionField is not { Editable: true } field)
|
|
return;
|
|
if (string.IsNullOrEmpty(_scribeName))
|
|
{
|
|
field.SetText(string.Empty);
|
|
_inscriptionValue = string.Empty;
|
|
}
|
|
_signatureValue = $"--{_playerName()}";
|
|
}
|
|
|
|
private void HandleInscriptionLosingFocus(string text)
|
|
{
|
|
if (_itemObjectId == 0 || !_presentationInscribable)
|
|
return;
|
|
|
|
bool skipEmptyUnscribed = string.IsNullOrEmpty(text)
|
|
&& string.IsNullOrEmpty(_scribeName);
|
|
if (!skipEmptyUnscribed
|
|
&& !string.Equals(
|
|
text,
|
|
_oldInscription,
|
|
StringComparison.Ordinal))
|
|
{
|
|
_sendSetInscription(_itemObjectId, text);
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(text))
|
|
{
|
|
_inscriptionValue = "<Inscribe here>";
|
|
_signatureValue = string.Empty;
|
|
_scribeName = string.Empty;
|
|
_inscriptionField?.SetText(_inscriptionValue);
|
|
}
|
|
else
|
|
{
|
|
_inscriptionValue = text;
|
|
_scribeName = _playerName();
|
|
_signatureValue = $"--{_scribeName}";
|
|
}
|
|
_oldInscription = text;
|
|
}
|
|
|
|
private void ReportInscriptionUnavailable()
|
|
{
|
|
if (_inscriptionField?.Editable == true)
|
|
return;
|
|
if (!string.IsNullOrEmpty(_scribeName)
|
|
&& !string.Equals(
|
|
_scribeName,
|
|
_playerName(),
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_systemMessage($"Only {_scribeName} can change the inscription");
|
|
return;
|
|
}
|
|
if (_itemObjectId == 0
|
|
|| !_interaction.IsOwnedByPlayer(_itemObjectId))
|
|
{
|
|
_systemMessage("Item must be in your inventory to inscribe.");
|
|
return;
|
|
}
|
|
_systemMessage("This item is not inscribable.");
|
|
}
|
|
|
|
private void ApplyCreature(
|
|
ClientObject obj,
|
|
AppraiseInfoParser.Parsed appraisal,
|
|
bool character,
|
|
bool newlySelected)
|
|
{
|
|
ClearCreatureText();
|
|
PropertyBundle p = appraisal.Properties;
|
|
int level = GetInt(p, 25u);
|
|
SetText(
|
|
CreatureLevelValueId,
|
|
level > 0
|
|
? level.ToString(CultureInfo.InvariantCulture)
|
|
: "???");
|
|
|
|
if (character)
|
|
{
|
|
SetText(0x10000150u, BuildCharacterHeritageDisplay(p));
|
|
SetText(0x10000151u, BuildCharacterTitleDisplay(p));
|
|
SetText(0x10000152u, BuildPlayerKillerDisplay(obj));
|
|
SetText(0x1000053Au, BuildAllegianceDisplay(p));
|
|
}
|
|
else
|
|
{
|
|
SetText(
|
|
CreatureDisplayNameId,
|
|
_creatureNames.Resolve(GetInt(p, 2u)));
|
|
}
|
|
|
|
RebuildCreatureStats(appraisal);
|
|
|
|
if (newlySelected)
|
|
ResetCreatureScroll();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composes examination element <c>0x10000150</c> (Heritage) for a
|
|
/// player target. Ground truth: <c>docs/research/2026-08-25-campaign-as-
|
|
/// ground-truth.md</c> §2a. Gap G1.
|
|
/// </summary>
|
|
private string BuildCharacterHeritageDisplay(PropertyBundle p)
|
|
{
|
|
int gender = GetInt(p, CharacterIdentityText.GenderPropertyId);
|
|
int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId);
|
|
string creatureFallback = heritageGroup == 0
|
|
? _creatureNames.Resolve(GetInt(p, 2u))
|
|
: string.Empty;
|
|
return CharacterIdentityText.GenderHeritageDisplay(
|
|
gender, heritageGroup, creatureFallback);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composes examination element <c>0x10000151</c> (Profession/title) for
|
|
/// a player target: the current display title from Int 261
|
|
/// CharacterTitleId (<see cref="_resolveCharacterTitle"/>, resolved
|
|
/// through <c>CharacterTitleResolver</c>'s <c>CharacterTitleTable::
|
|
/// GetCharacterTitleFromID @0x005C6ED0</c> chain), falling back to
|
|
/// String 5 Template verbatim when the id is absent or unresolvable.
|
|
/// Ground truth §2a, gap G2. <see cref="CharacterMarkerIntProperty"/>
|
|
/// (0x105 == 261 decimal) is the SAME property <see cref="SelectView"/>
|
|
/// already keys the Character-subview dispatch on.
|
|
/// </summary>
|
|
private string BuildCharacterTitleDisplay(PropertyBundle p)
|
|
{
|
|
if (p.Ints.TryGetValue(CharacterMarkerIntProperty, out int titleId)
|
|
&& titleId != 0
|
|
&& _resolveCharacterTitle(unchecked((uint)titleId)) is { Length: > 0 } resolved)
|
|
{
|
|
return resolved;
|
|
}
|
|
return GetString(p, TemplateStringProperty);
|
|
}
|
|
|
|
/// <summary>PWD bit 5 — <c>ACCWeenieObject::IsPK @0x0058C8B0</c>.</summary>
|
|
private const uint PlayerKillerPwdBit = 0x20u;
|
|
|
|
/// <summary>PWD bit 0x19 (25) — <c>ACCWeenieObject::IsPKLite @0x0058C8A0</c>.</summary>
|
|
private const uint PlayerKillerLitePwdBit = 0x02000000u;
|
|
|
|
/// <summary>
|
|
/// Composes examination element <c>0x10000152</c> (PlayerKiller) for a
|
|
/// player target. Ruling R7: retail reads the LOCAL weenie's PWD bits —
|
|
/// never the appraisal payload's PropertyInt 134 (AP-109's CT4 lesson).
|
|
/// <paramref name="obj"/> is the SAME <see cref="ClientObject"/>
|
|
/// <see cref="BuildTitle"/> already resolves names from — the assessed
|
|
/// object <see cref="Apply"/> located before <c>ApplyCreature</c> ever
|
|
/// runs. When that lookup fails, <see cref="Apply"/> already bails out
|
|
/// before reaching this method, matching retail's "weenie is gone, leave
|
|
/// the line cleared" behavior. Ground truth §2a, gap G3.
|
|
/// </summary>
|
|
private static string BuildPlayerKillerDisplay(ClientObject obj)
|
|
{
|
|
uint bitfield = obj.PublicWeenieBitfield ?? 0u;
|
|
if ((bitfield & PlayerKillerPwdBit) != 0u)
|
|
return "Player Killer";
|
|
if ((bitfield & PlayerKillerLitePwdBit) != 0u)
|
|
return "Player Killer Lite";
|
|
return "Non-Player Killer";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composes examination element <c>0x1000053A</c> (AllegianceName).
|
|
/// Retail clears the element first (<see cref="ClearCreatureText"/>
|
|
/// already does this for every <c>ApplyCreature</c> call), then sets it
|
|
/// to String 47 AllegianceName ONLY inside the Int 30 AllegianceRank
|
|
/// >= 1 gate — no gate, no allegiance line. Deletes the former
|
|
/// invented "Assessment incomplete" literal (zero retail provenance;
|
|
/// was the only occurrence in the repo). Ground truth §2a, gap G5.
|
|
/// Retail's <c>CreatureExamineUI</c> (monsters) never binds this
|
|
/// element at all — only <c>CharExamineUI</c> (players) does — so this
|
|
/// composer is scoped to the <c>character</c> branch, matching that
|
|
/// per-class split.
|
|
/// </summary>
|
|
private static string BuildAllegianceDisplay(PropertyBundle p)
|
|
=> GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty;
|
|
|
|
private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal)
|
|
{
|
|
if (_creatureStats is null || _creatureRowTemplates is null)
|
|
return;
|
|
|
|
_creatureStats.Rebuild(
|
|
appraisal.CreatureProfile is { } profile
|
|
? CreatureAppraisalRows.Build(profile, appraisal.Success)
|
|
: Array.Empty<CreatureAppraisalRow>());
|
|
_creatureExtra?.Rebuild(
|
|
CreatureAppraisalRows.BuildExtra(appraisal.Properties));
|
|
}
|
|
|
|
private void ConfigureScrollableText(
|
|
UiText text,
|
|
uint scrollbarId,
|
|
Func<string> value)
|
|
{
|
|
text.PreserveEndOnLayout = false;
|
|
text.WheelScrollEnabled = true;
|
|
text.ClickThrough = false;
|
|
BindTextSource(
|
|
text,
|
|
value,
|
|
static (target, content) =>
|
|
IndicatorDetailText.Shape(target, content));
|
|
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
|
scrollbar.Model = text.Scroll;
|
|
}
|
|
|
|
private void BindTextSource(
|
|
UiText text,
|
|
Func<string> source,
|
|
Func<UiText, string, IReadOnlyList<UiText.Line>> shape)
|
|
{
|
|
var cache = new UiTextLayoutCache<string>(
|
|
text,
|
|
shape,
|
|
source,
|
|
StringComparer.Ordinal);
|
|
_textLayouts.Add(text, cache);
|
|
text.LinesProvider = cache.Provider;
|
|
}
|
|
|
|
private UiTextLayoutCache<string> GetTextLayout(UiText text)
|
|
{
|
|
if (_textLayouts.TryGetValue(
|
|
text,
|
|
out UiTextLayoutCache<string>? cache))
|
|
{
|
|
return cache;
|
|
}
|
|
|
|
cache = new UiTextLayoutCache<string>(
|
|
text,
|
|
static (target, value) =>
|
|
IndicatorDetailText.Shape(target, value),
|
|
string.Empty,
|
|
StringComparer.Ordinal);
|
|
_textLayouts.Add(text, cache);
|
|
text.LinesProvider = cache.Provider;
|
|
return cache;
|
|
}
|
|
|
|
private void ConfigureScrollableItemText(
|
|
UiText text,
|
|
uint scrollbarId)
|
|
{
|
|
text.PreserveEndOnLayout = false;
|
|
text.WheelScrollEnabled = true;
|
|
text.ClickThrough = false;
|
|
_itemTextLayout = new UiTextLayoutCache<ItemAppraisalReport>(
|
|
text,
|
|
static (target, report) =>
|
|
ItemAppraisalTextLayout.Shape(target, report),
|
|
() => _itemReport,
|
|
ReferenceEqualityComparer.Instance);
|
|
text.LinesProvider = _itemTextLayout.Provider;
|
|
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
|
|
scrollbar.Model = text.Scroll;
|
|
}
|
|
|
|
private static void ConfigureSpellText(UiText text, bool wrap = false)
|
|
{
|
|
text.PreserveEndOnLayout = false;
|
|
text.ClickThrough = true;
|
|
if (wrap)
|
|
{
|
|
text.OneLine = false;
|
|
text.VerticalJustify = VJustify.Top;
|
|
}
|
|
}
|
|
|
|
private void SetSpellText(UiText text, string value)
|
|
=> GetTextLayout(text).SetValue(value);
|
|
|
|
private static string BuildSpellDisplay(
|
|
SpellMetadata metadata,
|
|
IReadOnlyList<SpellExamineComponent> components)
|
|
{
|
|
var display = new StringBuilder(metadata.Description);
|
|
if (components.Count == 0)
|
|
return display.ToString();
|
|
|
|
display.Append("\nCOMPONENTS:");
|
|
foreach (SpellExamineComponent component in components)
|
|
display.Append("\n ").Append(component.Descriptor.Name);
|
|
return display.ToString();
|
|
}
|
|
|
|
private void RebuildSpellFormula(
|
|
IReadOnlyList<SpellExamineComponent> components)
|
|
{
|
|
ClearSpellFormula();
|
|
if (_spellComponentTemplates is null || components.Count == 0)
|
|
{
|
|
_spellFormulaHost.Width = _spellFormulaCellWidth;
|
|
_spellFormulaHost.Left = _spellFormulaOriginX;
|
|
return;
|
|
}
|
|
|
|
float cellWidth = _spellFormulaCellWidth;
|
|
_spellFormulaHost.Left =
|
|
_spellFormulaOriginX - cellWidth * 0.5f * (components.Count - 1);
|
|
_spellFormulaHost.Width = cellWidth * components.Count;
|
|
_spellFormulaHost.ResetAnchorCapture();
|
|
for (int i = 0; i < components.Count; i++)
|
|
{
|
|
SpellExamineComponent component = components[i];
|
|
UiElement cell = _spellComponentTemplates.Create(
|
|
_resolveComponentIcon(component.Descriptor.IconId),
|
|
component.Owned);
|
|
cell.LayoutPolicy = null;
|
|
cell.Anchors = AnchorEdges.Left | AnchorEdges.Top;
|
|
cell.Left = i * cellWidth;
|
|
cell.Top = 0f;
|
|
_spellFormulaHost.AddChild(cell);
|
|
_spellFormulaCells.Add(cell);
|
|
}
|
|
}
|
|
|
|
private void ClearSpellFormula()
|
|
{
|
|
foreach (UiElement cell in _spellFormulaCells)
|
|
_spellFormulaHost.RemoveChild(cell);
|
|
_spellFormulaCells.Clear();
|
|
_spellFormulaHost.Left = _spellFormulaOriginX;
|
|
_spellFormulaHost.Width = _spellFormulaCellWidth;
|
|
_spellFormulaHost.ResetAnchorCapture();
|
|
}
|
|
|
|
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;
|
|
GetTextLayout(text).SetValue(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[]
|
|
{
|
|
CreatureLevelValueId, CreatureDisplayNameId,
|
|
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
|
|
})
|
|
if (_layout.FindElement(id) is UiText text)
|
|
GetTextLayout(text).SetValue(string.Empty);
|
|
_creatureStats?.Flush();
|
|
_creatureExtra?.Flush();
|
|
}
|
|
|
|
private void ResetCreatureScroll()
|
|
{
|
|
_creatureStats?.ResetScroll();
|
|
_creatureExtra?.ResetScroll();
|
|
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;
|
|
_spellPanel.Visible = view == AppraisalView.Spell;
|
|
|
|
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 SpellMetadata? ResolveSpell(uint spellId)
|
|
=> _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata)
|
|
? metadata
|
|
: 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 string BuildTitle(ClientObject obj, PropertyBundle properties)
|
|
{
|
|
string name = GetString(properties, DisplayedNameStringProperty);
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
name = _itemNames.ResolveAppropriateName(obj);
|
|
return obj.StackSize > 1
|
|
? $"{obj.StackSize.ToString(CultureInfo.InvariantCulture)} {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()
|
|
{
|
|
_windowVisible = true;
|
|
}
|
|
|
|
public void OnHidden() => _windowVisible = false;
|
|
|
|
/// <summary>
|
|
/// Port of <c>gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0</c>.
|
|
/// Retail initializes <c>m_examineNewlySelectedItem</c> to one: while the
|
|
/// floaty window is visible, a new selection is appraised immediately and
|
|
/// clearing selection hides the window.
|
|
/// </summary>
|
|
private void HandleSelectionChanged(SelectionTransition transition)
|
|
{
|
|
if (!_windowVisible || _disposed)
|
|
return;
|
|
|
|
if (transition.SelectedObjectId is uint objectId && objectId != 0u)
|
|
{
|
|
_interaction.ExamineSelectedOrEnterMode(objectId);
|
|
return;
|
|
}
|
|
|
|
_closeWindow();
|
|
}
|
|
|
|
private void HandleSpellComponentObjectChanged(ClientObject _)
|
|
=> RefreshSpellComponents();
|
|
|
|
private void HandleSpellComponentObjectMoved(ClientObjectMove _)
|
|
=> RefreshSpellComponents();
|
|
|
|
private void HandleSpellComponentObjectsCleared()
|
|
=> RefreshSpellComponents();
|
|
|
|
private void RefreshSpellComponents()
|
|
{
|
|
if (_activeView != AppraisalView.Spell
|
|
|| _spellId == 0u
|
|
|| !_spellbook.TryGetMetadata(_spellId, out SpellMetadata metadata))
|
|
return;
|
|
|
|
IReadOnlyList<SpellExamineComponent> components =
|
|
_spellComponents(_spellId);
|
|
SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
|
|
RebuildSpellFormula(components);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
_selection.Changed -= HandleSelectionChanged;
|
|
_objects.ObjectAdded -= HandleSpellComponentObjectChanged;
|
|
_objects.ObjectMoved -= HandleSpellComponentObjectMoved;
|
|
_objects.ObjectRemoved -= HandleSpellComponentObjectChanged;
|
|
_objects.StackSizeUpdated -= HandleSpellComponentObjectChanged;
|
|
_objects.Cleared -= HandleSpellComponentObjectsCleared;
|
|
if (_close is not null)
|
|
_close.OnClick = null;
|
|
if (_inscriptionField is not null)
|
|
{
|
|
_inscriptionField.OnFocusGained = null;
|
|
_inscriptionField.OnFocusLost = null;
|
|
_inscriptionField.OnReadOnlyClick = null;
|
|
}
|
|
if (_inscriptionBackground is not null)
|
|
_inscriptionBackground.OnClick = null;
|
|
ClearSpellFormula();
|
|
_spellIconHost.RemoveChild(_spellIcon);
|
|
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
|
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
|
|| (_inscriptionText is not null
|
|
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll))
|
|
|| (_inscriptionField is not null
|
|
&& ReferenceEquals(scrollbar.Model, _inscriptionField.Scroll)))
|
|
scrollbar.Model = null;
|
|
}
|
|
}
|
|
|
|
public enum AppraisalView
|
|
{
|
|
Item,
|
|
Creature,
|
|
Character,
|
|
Spell,
|
|
}
|