using System.Globalization; using System.Numerics; using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.App.UI.Layout; public enum CreatureAppraisalValueStyle { Normal, Positive, Negative, Incomplete, } public readonly record struct CreatureAppraisalRow( string Label, string Value, CreatureAppraisalValueStyle Style); public enum CreatureAppraisalRowLayer { Combined, Background, Foreground, } /// /// Pure projection of retail's six AttributeInfoRegion and three /// Attribute2ndInfoRegion tokens. Ordering and formatting come from /// BasicCreatureExamineUI and the two Update overloads at /// 0x004F1D90/0x004F1E80. /// public static class CreatureAppraisalRows { private const string Unknown = "???"; private const uint DamageRating = 0x133u; private const uint DamageResistRating = 0x134u; private const uint CritRating = 0x139u; private const uint CritDamageRating = 0x13Au; private const uint CritResistRating = 0x13Bu; private const uint CritDamageResistRating = 0x13Cu; private const uint HealingBoostRating = 0x143u; private const uint DotResistRating = 0x15Eu; private const uint LifeResistRating = 0x15Fu; public static IReadOnlyList Build( AppraiseInfoParser.CreatureProfile profile, bool success) { return [ Primary("Strength", profile.Strength, 0, profile, success), Primary("Endurance", profile.Endurance, 1, profile, success), Primary("Coordination", profile.Coordination, 3, profile, success), Primary("Quickness", profile.Quickness, 2, profile, success), Primary("Focus", profile.Focus, 4, profile, success), Primary("Self", profile.Self, 5, profile, success), Secondary( "Health", profile.Health, profile.HealthMax, showPercent: true, enchantmentBit: 6, profile, success), Secondary( "Stamina", profile.Stamina, profile.StaminaMax, showPercent: false, enchantmentBit: 7, profile, success), Secondary( "Mana", profile.Mana, profile.ManaMax, showPercent: false, enchantmentBit: 8, profile, success), ]; } /// /// Port of CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0's /// separate miscellaneous list. Retail reads all nine rating properties, /// emits a leading and trailing blank row when any display group exists, /// and uses Crit/CritResist only to decide whether their paired row exists. /// HealingBoost is read but not displayed by this retail build. /// public static IReadOnlyList BuildExtra( PropertyBundle properties) { ArgumentNullException.ThrowIfNull(properties); int damage = Get(properties, DamageRating); int damageResist = Get(properties, DamageResistRating); int crit = Get(properties, CritRating); int critDamage = Get(properties, CritDamageRating); int critResist = Get(properties, CritResistRating); int critDamageResist = Get(properties, CritDamageResistRating); _ = Get(properties, HealingBoostRating); int dotResist = Get(properties, DotResistRating); int lifeResist = Get(properties, LifeResistRating); bool showRating = damage > 0 || crit > 0 || critDamage > 0; bool showResist = damageResist > 0 || critResist > 0 || critDamageResist > 0; bool showDotLife = dotResist > 0 || lifeResist > 0; if (!showRating && !showResist && !showDotLife) return Array.Empty(); var rows = new List(5) { Blank(), }; if (showRating) { rows.Add(new CreatureAppraisalRow( "Dmg/CritDmg", $"Rating: {Number(damage)}/{Number(critDamage)}", CreatureAppraisalValueStyle.Normal)); } if (showResist) { rows.Add(new CreatureAppraisalRow( "Dmg/CritDmg", $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", CreatureAppraisalValueStyle.Normal)); } if (showDotLife) { rows.Add(new CreatureAppraisalRow( "DoT/Life:", $"Resist: {Number(dotResist)}/{Number(lifeResist)}", CreatureAppraisalValueStyle.Normal)); } rows.Add(Blank()); return rows; } private static CreatureAppraisalRow Blank() => new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal); private static int Get(PropertyBundle properties, uint id) => properties.Ints.TryGetValue(id, out int value) ? value : 0; private static string Number(int value) => value.ToString(CultureInfo.CurrentCulture); private static CreatureAppraisalRow Primary( string label, uint? value, int enchantmentBit, AppraiseInfoParser.CreatureProfile profile, bool success) => new( label, value is > 0 ? value.Value.ToString(CultureInfo.CurrentCulture) : Unknown, Style(enchantmentBit, profile, success)); private static CreatureAppraisalRow Secondary( string label, uint? current, uint? maximum, bool showPercent, int enchantmentBit, AppraiseInfoParser.CreatureProfile profile, bool success) { string value = Unknown; if (current is > 0 && maximum is > 0) { int percent = RoundedPercent(current.Value, maximum.Value); if (success) { value = showPercent ? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)" : $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}"; } else if (showPercent) { value = $"{percent.ToString(CultureInfo.CurrentCulture)} %"; } } return new CreatureAppraisalRow( label, value, Style(enchantmentBit, profile, success)); } private static int RoundedPercent(uint numerator, uint denominator) => denominator == 0u ? 0 : (int)Math.Min( int.MaxValue, ((100L * numerator) + denominator / 2L) / denominator); private static CreatureAppraisalValueStyle Style( int bit, AppraiseInfoParser.CreatureProfile profile, bool success) { if (!success) return CreatureAppraisalValueStyle.Incomplete; ushort highlight = profile.AttributeHighlights ?? 0; if ((highlight & (1 << bit)) == 0) return CreatureAppraisalValueStyle.Normal; ushort color = profile.AttributeColors ?? 0; return (color & (1 << bit)) != 0 ? CreatureAppraisalValueStyle.Positive : CreatureAppraisalValueStyle.Negative; } } /// /// Instantiates LayoutDesc 0x2100006B's InfoRegion token template /// 0x10000166, matching UIElement_ListBox::AddItemFromTemplateList. /// public sealed class CreatureAppraisalRowTemplateFactory { public const uint TemplateId = 0x10000166u; public const uint LabelId = 0x1000012Au; public const uint ValueId = 0x1000012Bu; private readonly ElementInfo _template; private readonly Func _resolveSprite; private readonly UiDatFont? _defaultFont; private readonly IReadOnlyDictionary _fonts; public CreatureAppraisalRowTemplateFactory( ElementInfo template, Func resolveSprite, UiDatFont? defaultFont, IReadOnlyDictionary? fonts = null) { _template = template ?? throw new ArgumentNullException(nameof(template)); _resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite)); _defaultFont = defaultFont; _fonts = fonts ?? new Dictionary(); } public float Width => _template.Width; public float Height => _template.Height; public static CreatureAppraisalRowTemplateFactory? TryLoad( IDatReaderWriter dats, Func resolveSprite, UiDatFont? defaultFont, Func? resolveFont) { ElementInfo? template = LayoutImporter.ImportInfos( dats, AppraisalUiController.LayoutId, TemplateId); if (template is null || Find(template, LabelId) is null || Find(template, ValueId) is null || template.Width <= 0f || template.Height <= 0f) { return null; } var fonts = new Dictionary(); CaptureFonts(template, resolveFont, fonts); return new CreatureAppraisalRowTemplateFactory( template, resolveSprite, defaultFont, fonts); } public UiTemplateListSlot Create( CreatureAppraisalRow row, CreatureAppraisalRowLayer layer = CreatureAppraisalRowLayer.Combined) { ImportedLayout content = LayoutImporter.Build( _template, _resolveSprite, _defaultFont, did => _fonts.TryGetValue(did, out UiDatFont? font) ? font : _defaultFont); UiText label = Required(content, LabelId); UiText value = Required(content, ValueId); if (layer == CreatureAppraisalRowLayer.Background) { label.LinesProvider = static () => Array.Empty(); value.LinesProvider = static () => Array.Empty(); } else { label.LinesProvider = () => [new UiText.Line(row.Label, label.DefaultColor)]; value.LinesProvider = () => [new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))]; } if (layer == CreatureAppraisalRowLayer.Foreground && content.Root is UiDatElement root) { root.MediaVisible = false; } return new UiTemplateListSlot( content, entryId: 0u, content.Root is IUiDatStateful stateful ? stateful.ActiveRetailStateId : UiStateInfo.DirectStateId, selectedState: null); } private static Vector4 ResolveColor( CreatureAppraisalValueStyle style, Vector4 normal) { // Retail selects one of four authored FontInfo entries here. Keep the // semantic state in the row model, but do not invent substitute RGB // values while that FontInfo-list binding remains unported (AP-110). _ = style; return normal; } private static T Required(ImportedLayout content, uint id) where T : UiElement => content.FindElement(id) as T ?? throw new InvalidOperationException( $"Retail creature appraisal template element 0x{id:X8} did not resolve to {typeof(T).Name}."); private static ElementInfo? Find(ElementInfo root, uint id) { if (root.Id == id) return root; foreach (ElementInfo child in root.Children) if (Find(child, id) is { } found) return found; return null; } private static void CaptureFonts( ElementInfo info, Func? resolveFont, Dictionary fonts) { if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid)) fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid); foreach (ElementInfo child in info.Children) CaptureFonts(child, resolveFont, fonts); } } /// /// Reproduces retail viewport compositing with the modern RTT seam: /// authored row chrome is below the creature viewport, while a media-free /// instance of the same authored template keeps label/value text above it. /// Both lists share one pixel scroll model so their rows cannot drift. /// public sealed class CreatureAppraisalLayeredList { public const float TextInset = 8f; private readonly CreatureAppraisalRowTemplateFactory _templates; private CreatureAppraisalLayeredList( CreatureAppraisalRowTemplateFactory templates, UiItemList background, UiItemList foreground) { _templates = templates; Background = background; Foreground = foreground; } public UiItemList Background { get; } public UiItemList Foreground { get; } public static CreatureAppraisalLayeredList Create( UiElement panel, UiElement backgroundHost, UiViewport viewport, CreatureAppraisalRowTemplateFactory templates, int backgroundZOrder) { ArgumentNullException.ThrowIfNull(panel); ArgumentNullException.ThrowIfNull(backgroundHost); ArgumentNullException.ThrowIfNull(viewport); ArgumentNullException.ThrowIfNull(templates); int foregroundZOrder = backgroundHost.ZOrder; backgroundHost.ZOrder = backgroundZOrder; var scroll = new UiScrollable(); var background = NewList( // The authored row chrome keeps its LayoutDesc origin. Only the // foreground text is inset; moving both layers clips the left // edge of the retail separator/background media. left: 0f, top: 0f, width: templates.Width, height: backgroundHost.Height, zOrder: 0, scroll: scroll); background.ClickThrough = true; backgroundHost.AddChild(background); var foreground = NewList( left: backgroundHost.Left + TextInset, top: backgroundHost.Top, width: templates.Width, height: backgroundHost.Height, zOrder: foregroundZOrder, scroll: scroll); foreground.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; panel.AddChild(foreground); return new CreatureAppraisalLayeredList( templates, background, foreground); } public void Rebuild(IReadOnlyList rows) { ArgumentNullException.ThrowIfNull(rows); using (Background.DeferLayout()) using (Foreground.DeferLayout()) { Background.Flush(); Foreground.Flush(); foreach (CreatureAppraisalRow row in rows) { Background.AddItem(_templates.Create( row, CreatureAppraisalRowLayer.Background)); Foreground.AddItem(_templates.Create( row, CreatureAppraisalRowLayer.Foreground)); } } } public void Flush() { Background.Flush(); Foreground.Flush(); } public void ResetScroll() => Foreground.Scroll.SetScrollY(0); private static UiItemList NewList( float left, float top, float width, float height, int zOrder, UiScrollable scroll) { return new UiItemList(scroll: scroll) { Left = left, Top = top, Width = width, Height = height, ZOrder = zOrder, Columns = 1, CellWidth = width, CellHeight = 20f, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; } } /// /// Retail AppraisalSystem::InqCreatureDisplayName @ 0x005B59E0 resolves /// creature enum 0x10000005 through portal EnumMapper 0x2200000E and replaces /// underscores with spaces. /// public sealed class CreatureDisplayNameResolver { public const uint MapperDid = 0x2200000Eu; private readonly IReadOnlyDictionary _names; public CreatureDisplayNameResolver( IReadOnlyDictionary names) { _names = names ?? throw new ArgumentNullException(nameof(names)); } public static CreatureDisplayNameResolver Load(IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(dats); var names = new Dictionary(); var visited = new HashSet(); uint did = MapperDid; while (did != 0u && visited.Add(did)) { EnumMapper? mapper = dats.Get(did); if (mapper is null) break; foreach ((uint id, PStringBase text) in mapper.IdToStringMap) names.TryAdd(id, text.Value.Replace('_', ' ')); did = mapper.BaseEnumMap; } return new CreatureDisplayNameResolver(names); } public string Resolve(int creatureType) => creatureType > 0 && _names.TryGetValue((uint)creatureType, out string? name) ? name : string.Empty; }