diff --git a/docs/ISSUES.md b/docs/ISSUES.md index a60c5f49..45787297 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -76,7 +76,7 @@ Copy this block when adding a new issue: ## #234 — Monster assessment held busy and examination used the shared panel -**Status:** DONE — 2026-07-23; creature presentation visual gate pending +**Status:** DONE — 2026-07-23; corrected creature presentation visual gate pending **Severity:** HIGH **Filed:** 2026-07-23 **Component:** appraisal protocol / retained examination UI / item writing @@ -98,6 +98,10 @@ retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules. The follow-up creature page now uses the authored row template and exact retail stat order/formatting, creature-type EnumMapper, level property, fixed-heading animated clone, bounding-box camera, and private-viewport light. +Its correction ports the second authored damage/critical/resistance rating +list, composites row chrome behind the animated preview and row text in front, +adds balanced text inset, and automatically reassesses each new selection +while the floaty remains visible. **Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs`; @@ -113,7 +117,9 @@ examination window coexists with the shared primary panel; an owned inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/ noninscribable cases show retail's exact permission messages. Monster pages show the animated target, mapped creature type, level, and nine ordered -retail stat rows. +retail stat rows; rated creatures show retail's paired rating rows; the +animated target remains above row chrome but below text; changing selection +updates the open window without another magnifier click. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index bda5e686..38c2f369 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | -| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, and retail creature stat/animated-preview presentation, but still lacks item-object preview, exhaustive specialized detail/extra-rating regions, exact appraisal FontInfo-list selection, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, item/creature/character reports, inscription transaction, retail creature stat/rating/animated-preview presentation, and default selection-follow, but still lacks item-object preview, exhaustive specialized item/character detail regions, and exact appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview loop cover the active loops; the remaining examination mechanisms require additional retail field/font/object-preview projections rather than fabricated content | Specialized item/character assessments omit some retail fields and item-object presentation; enchanted/incomplete appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 48200bc2..6de8c5db 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -137,8 +137,12 @@ resolves its type through retail EnumMapper `0x2200000E`, preserves the authored Character/Level header, creates the exact nine stat rows from template `0x10000166`, and renders a fixed-heading animated clone through a private viewport using retail's bounding-box camera and distant light. -Item-object preview, specialized detail regions, exact appraisal font-state -selection, and selection-follow remain the narrowed AP-110 residual. Research: +The visual-gate correction also ports the separate `0x10000335` +damage/critical/resistance rating list, places authored row chrome behind the +animated preview and text in front, adds the balanced row inset, and follows +the current selection automatically while the examination window is visible. +Item-object preview, specialized item/character detail regions, and exact +appraisal font-state selection remain the narrowed AP-110 residual. Research: [`../research/2026-07-23-retail-appraisal-ui-pseudocode.md`](../research/2026-07-23-retail-appraisal-ui-pseudocode.md). The focused parser/router/request/controller/fixture tests pass, as do the Release solution build with 17 pre-existing tracked test warnings, 3,496 App @@ -151,6 +155,11 @@ updates, hydrated mesh bounds, and retail camera fitting. The warning-free App c Release solution build, 3,506 App tests / 3 skips, and 7,923 complete-solution tests / 5 skips pass. The connected visual result remains the closeout gate. +The rating/layering/selection-follow correction adds 18 focused green tests, +passes the Release solution build with the 17 warnings already tracked by +#228, 3,510 App tests / 3 skips, and 7,927 complete-solution tests / 5 skips. +Its corrected connected visual result remains the closeout gate. + ## Slice 1 — spell-bar overflow arrows ### Retail oracle diff --git a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md index a748fca9..343309ad 100644 --- a/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md +++ b/docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md @@ -191,16 +191,27 @@ on time pulse: send another Event_Appraise(currentAppraisalId) ``` -When the examination window's selection-follow option is active, a selection -change appraises the new selected GUID; clearing selection hides the window. -That optional retail preference remains outside this slice. +`gmExaminationUI::gmExaminationUI @ 0x004AB2B0` initializes +`m_examineNewlySelectedItem` to one. Selection-follow is therefore retail's +default examination behavior, not an optional preference: + +```text +on SelectionChanged: + if examination window is not visible: + return + if selected object id != 0: + ClientUISystem.ExamineObject(selected id) + else: + hide examination window +``` ## Creature presentation -`BasicCreatureExamineUI` binds viewport `0x10000148`, list box `0x10000149`, -level value `0x1000014C`, and creature display name `0x1000014E`. Its stat -rows are instances of LayoutDesc `0x2100006B` template `0x10000166`, whose -label and value fields are `0x1000012A` and `0x1000012B`. +`BasicCreatureExamineUI` binds viewport `0x10000148`, base-stat list box +`0x10000149`, miscellaneous-rating list box `0x10000335`, level value +`0x1000014C`, and creature display name `0x1000014E`. Its rows are instances +of LayoutDesc `0x2100006B` template `0x10000166`, whose label and value fields +are `0x1000012A` and `0x1000012B`. ```text on creature appraisal: @@ -222,6 +233,27 @@ on creature appraisal: Health current secondary 2 / maximum secondary 1, show percent Stamina current secondary 4 / maximum secondary 3 Mana current secondary 6 / maximum secondary 5 + + clear miscellaneous list + damageRating = int property 0x133 + damageResistRating = int property 0x134 + critRating = int property 0x139 + critDamageRating = int property 0x13A + critResistRating = int property 0x13B + critDamageResist = int property 0x13C + healingBoostRating = int property 0x143 + dotResistRating = int property 0x15E + lifeResistRating = int property 0x15F + + if any displayed rating group is nonzero: + append blank row + if damageRating or critRating or critDamageRating is positive: + append "Dmg/CritDmg" = "Rating: damageRating/critDamageRating" + if damageResistRating or critResistRating or critDamageResist is positive: + append "Dmg/CritDmg" = "Resist: damageResistRating/critDamageResist" + if dotResistRating or lifeResistRating is positive: + append "DoT/Life:" = "Resist: dotResistRating/lifeResistRating" + append blank row ``` Successful primary values are decimal integers. Successful Health is @@ -230,6 +262,18 @@ assessment, Health still shows the percentage when both values are known, while every other row is `???`. The profile highlight/color bitfields select one of retail's four authored FontInfo entries (normal, beneficial, detrimental, or incomplete); exact FontInfo-list binding remains AP-110. +The 2013 client reads HealingBoost but does not append it to this creature +rating list. CritRating and CritResistRating determine whether their paired +row exists, while the displayed right-hand value is CritDamageRating or +CritDamageResistRating exactly as retail formats it. + +The authored viewport and list overlap. Retail draws the creature between the +row chrome and the row text. The modern retained RTT seam reproduces that +ordering with two synchronized copies of the same DAT row template: media-only +chrome below the viewport and media-free label/value descendants above it. +Both copies share one pixel scroll model. The 292-pixel authored row is inset +four pixels within the 300-pixel list surface, giving the text a balanced +left/right margin without changing template geometry. The animated preview is a private `CreatureMode`, not the live world object: @@ -412,9 +456,9 @@ implements the same behavior. The request lifetime, exact packet flags, independent floaty window, inscription permission/edit/write transaction, subview choice, authored outer -layout, ordered creature stats, animated creature preview, scrollbar -ownership, and combat refresh are in scope. Retail's item-object preview, -exhaustive specialized `ItemExamineUI` detail branches, creature -extra-rating regions, exact appraisal FontInfo-list selection, -selection-follow preference, and PSR-only scribe-account/override behavior -remain explicit AP-110 work. +layout, ordered creature stats and ratings, animated creature preview, +viewport/list/text compositing, selection-follow, scrollbar ownership, and +combat refresh are in scope. Retail's item-object preview, exhaustive +specialized `ItemExamineUI` detail branches, exact appraisal FontInfo-list +selection, and PSR-only scribe-account/override behavior remain explicit +AP-110 work. diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 7cd0d955..a4402d15 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -4,6 +4,7 @@ using System.Text; 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; @@ -29,6 +30,7 @@ public sealed class AppraisalUiController : IRetainedPanelController 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; @@ -41,12 +43,14 @@ public sealed class AppraisalUiController : IRetainedPanelController 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 _playerName; private readonly Action _sendSetInscription; private readonly Action _systemMessage; private readonly Action _show; + private readonly Action _closeWindow; private readonly UiElement _itemPanel; private readonly UiElement _creaturePanel; private readonly UiElement? _spellPanel; @@ -57,7 +61,8 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly UiText? _signature; private readonly UiDatElement? _inscriptionBackground; private readonly UiButton? _close; - private readonly UiItemList? _creatureStats; + private readonly CreatureAppraisalLayeredList? _creatureStats; + private readonly CreatureAppraisalLayeredList? _creatureExtra; private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates; private readonly CreatureDisplayNameResolver _creatureNames; private AppraisalView _activeView; @@ -71,6 +76,7 @@ public sealed class AppraisalUiController : IRetainedPanelController private string _scribeName = string.Empty; private string _oldInscription = string.Empty; private bool _presentationInscribable; + private bool _windowVisible; private double _refreshElapsed; private bool _disposed; @@ -78,6 +84,7 @@ public sealed class AppraisalUiController : IRetainedPanelController ImportedLayout layout, ClientObjectTable objects, ItemInteractionController interaction, + SelectionState selection, CombatState combat, Spellbook spellbook, Func playerName, @@ -95,12 +102,14 @@ public sealed class AppraisalUiController : IRetainedPanelController _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 = layout.FindElement(SpellPanelId); @@ -150,24 +159,25 @@ public sealed class AppraisalUiController : IRetainedPanelController } if (creatureRowTemplates is not null - && layout.FindElement(CreatureStatsListId) is { } statsHost) + && layout.FindElement(CreatureStatsListId) is { } statsHost + && layout.FindElement(CreatureExtraListId) is { } extraHost + && layout.FindElement(CreatureViewportId) is UiViewport viewport) { - _creatureStats = statsHost as UiItemList - ?? new UiItemList - { - Width = statsHost.Width, - Height = statsHost.Height, - Anchors = AnchorEdges.Left | AnchorEdges.Top - | AnchorEdges.Right | AnchorEdges.Bottom, - }; - if (!ReferenceEquals(_creatureStats, statsHost)) - statsHost.AddChild(_creatureStats); - _creatureStats.Flush(); - _creatureStats.Columns = 1; - _creatureStats.CellWidth = creatureRowTemplates.Width; - _creatureStats.CellHeight = creatureRowTemplates.Height; + _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; SetActiveView(AppraisalView.Item); } @@ -178,6 +188,7 @@ public sealed class AppraisalUiController : IRetainedPanelController ImportedLayout layout, ClientObjectTable objects, ItemInteractionController interaction, + SelectionState selection, CombatState combat, Spellbook spellbook, Func playerName, @@ -191,6 +202,7 @@ public sealed class AppraisalUiController : IRetainedPanelController ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); ArgumentNullException.ThrowIfNull(interaction); + ArgumentNullException.ThrowIfNull(selection); ArgumentNullException.ThrowIfNull(combat); ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(playerName); @@ -209,6 +221,7 @@ public sealed class AppraisalUiController : IRetainedPanelController layout, objects, interaction, + selection, combat, spellbook, playerName, @@ -274,7 +287,7 @@ public sealed class AppraisalUiController : IRetainedPanelController public void Tick(double deltaSeconds) { - if (!_layout.Root.Visible + if (!_windowVisible || _activeView is not (AppraisalView.Creature or AppraisalView.Character) || _combat.CurrentMode == CombatMode.NonCombat || CurrentObjectId == 0) @@ -522,18 +535,12 @@ public sealed class AppraisalUiController : IRetainedPanelController if (_creatureStats is null || _creatureRowTemplates is null) return; - using (_creatureStats.DeferLayout()) - { - _creatureStats.Flush(); - if (appraisal.CreatureProfile is not { } profile) - return; - foreach (CreatureAppraisalRow row in CreatureAppraisalRows.Build( - profile, - appraisal.Success)) - { - _creatureStats.AddItem(_creatureRowTemplates.Create(row)); - } - } + _creatureStats.Rebuild( + appraisal.CreatureProfile is { } profile + ? CreatureAppraisalRows.Build(profile, appraisal.Success) + : Array.Empty()); + _creatureExtra?.Rebuild( + CreatureAppraisalRows.BuildExtra(appraisal.Properties)); } private void ConfigureScrollableText( @@ -577,11 +584,13 @@ public sealed class AppraisalUiController : IRetainedPanelController if (_layout.FindElement(id) is UiText text) text.LinesProvider = static () => Array.Empty(); _creatureStats?.Flush(); + _creatureExtra?.Flush(); } private void ResetCreatureScroll() { - _creatureStats?.Scroll.SetScrollY(0); + _creatureStats?.ResetScroll(); + _creatureExtra?.ResetScroll(); foreach (UiText text in Descendants(_creaturePanel).OfType()) text.Scroll.SetScrollY(0); } @@ -647,6 +656,29 @@ public sealed class AppraisalUiController : IRetainedPanelController public void OnShown() { + _windowVisible = true; + } + + public void OnHidden() => _windowVisible = false; + + /// + /// Port of gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0. + /// Retail initializes m_examineNewlySelectedItem to one: while the + /// floaty window is visible, a new selection is appraised immediately and + /// clearing selection hides the window. + /// + private void HandleSelectionChanged(SelectionTransition transition) + { + if (!_windowVisible || _disposed) + return; + + if (transition.SelectedObjectId is uint objectId && objectId != 0u) + { + _interaction.ExamineSelectedOrEnterMode(objectId); + return; + } + + _closeWindow(); } public void Dispose() @@ -654,6 +686,7 @@ public sealed class AppraisalUiController : IRetainedPanelController if (_disposed) return; _disposed = true; + _selection.Changed -= HandleSelectionChanged; if (_close is not null) _close.OnClick = null; if (_inscriptionField is not null) diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index ef0139c4..f2dd57cd 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Numerics; using AcDream.Content; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; using DatReaderWriter; using DatReaderWriter.DBObjs; @@ -21,6 +22,13 @@ public readonly record struct CreatureAppraisalRow( 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 @@ -30,6 +38,15 @@ public readonly record struct CreatureAppraisalRow( 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, @@ -70,6 +87,73 @@ public static class CreatureAppraisalRows ]; } + /// + /// 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, @@ -198,7 +282,9 @@ public sealed class CreatureAppraisalRowTemplateFactory fonts); } - public UiTemplateListSlot Create(CreatureAppraisalRow row) + public UiTemplateListSlot Create( + CreatureAppraisalRow row, + CreatureAppraisalRowLayer layer = CreatureAppraisalRowLayer.Combined) { ImportedLayout content = LayoutImporter.Build( _template, @@ -209,10 +295,24 @@ public sealed class CreatureAppraisalRowTemplateFactory : _defaultFont); UiText label = Required(content, LabelId); UiText value = Required(content, ValueId); - label.LinesProvider = () => - [new UiText.Line(row.Label, label.DefaultColor)]; - value.LinesProvider = () => - [new UiText.Line(row.Value, ResolveColor(row.Style, value.DefaultColor))]; + 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, @@ -262,6 +362,124 @@ public sealed class CreatureAppraisalRowTemplateFactory } } +/// +/// 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 = 4f; + + 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( + left: TextInset, + 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 diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 474c8842..1905e469 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -158,10 +158,17 @@ public sealed class UiDatElement : UiElement, IUiDatStateful /// Label color (default white). public Vector4 LabelColor { get; set; } = Vector4.One; + /// + /// Controls only this element's authored media. Descendants still draw. + /// Layered retail composites use this to place a template's chrome behind a + /// viewport while retaining its text descendants above that viewport. + /// + public bool MediaVisible { get; set; } = true; + protected override void OnDraw(UiRenderContext ctx) { var (file, _) = ActiveMedia(); - if (file != 0) + if (MediaVisible && file != 0) { var (tex, tw, th) = _resolve(file); if (tex != 0 && tw != 0 && th != 0) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 49e744dd..aa614ef4 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -994,6 +994,7 @@ public sealed class RetailUiRuntime : IDisposable layout, _bindings.Inventory.Objects, _bindings.Inventory.ItemInteraction, + _bindings.Inventory.Selection, _bindings.Combat.State, _bindings.Magic.Spellbook, _bindings.Appraisal.PlayerName, diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index 39b1998a..3ddfa90e 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -26,10 +26,13 @@ public sealed class UiItemList : UiElement /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). - public UiScrollable Scroll { get; } = new(); + public UiScrollable Scroll { get; } - public UiItemList(Func? spriteResolve = null) + public UiItemList( + Func? spriteResolve = null, + UiScrollable? scroll = null) { + Scroll = scroll ?? new UiScrollable(); SpriteResolve = spriteResolve; // Single-cell default: every toolbar slot always shows one cell (empty or filled). AddItem(new UiItemSlot { SpriteResolve = spriteResolve }); diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index b7f536a2..f38e3a78 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -3,6 +3,7 @@ using AcDream.App.UI.Layout; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Selection; using AcDream.Core.Spells; namespace AcDream.App.Tests.UI.Layout; @@ -129,6 +130,7 @@ public sealed class AppraisalUiControllerTests Assert.True(controller.Apply(Parsed(properties, creature))); Assert.Equal(AppraisalView.Creature, controller.ActiveView); Assert.Equal(1, shown); + controller.OnShown(); Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible); Assert.True(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible); @@ -139,7 +141,7 @@ public sealed class AppraisalUiControllerTests Assert.Equal(new[] { ObjectId, ObjectId }, sent); Assert.Equal(0, interaction.BusyCount); - layout.Root.Visible = false; + controller.OnHidden(); controller.Tick(0.75); Assert.Equal(new[] { ObjectId, ObjectId }, sent); @@ -188,6 +190,8 @@ public sealed class AppraisalUiControllerTests var properties = new PropertyBundle(); properties.Ints[2u] = 77; properties.Ints[25u] = 80; + properties.Ints[0x133u] = 35; + properties.Ints[0x13Au] = 4; var profile = new AppraiseInfoParser.CreatureProfile( Flags: 0x08u, Health: 295u, @@ -224,7 +228,19 @@ public sealed class AppraisalUiControllerTests UiElement host = layout.FindElement( AppraisalUiController.CreatureStatsListId)!; - UiItemList list = Assert.Single(host.Children.OfType()); + UiElement creaturePanel = layout.FindElement( + AppraisalUiController.CreaturePanelId)!; + UiViewport viewport = Assert.IsType( + layout.FindElement(AppraisalUiController.CreatureViewportId)); + UiItemList background = Assert.Single( + host.Children.OfType()); + UiItemList list = Assert.Single( + creaturePanel.Children.OfType(), + candidate => candidate.Top == host.Top); + Assert.Equal(CreatureAppraisalLayeredList.TextInset, list.Left); + Assert.True(host.ZOrder < viewport.ZOrder); + Assert.True(viewport.ZOrder < list.ZOrder); + Assert.Same(background.Scroll, list.Scroll); Assert.Equal(9, list.GetNumUIItems()); string[] labels = new string[9]; string[] values = new string[9]; @@ -253,6 +269,97 @@ public sealed class AppraisalUiControllerTests "295/295 (100 %)", "190/190", "550/550", ], values); + + UiElement extraHost = layout.FindElement( + AppraisalUiController.CreatureExtraListId)!; + UiItemList extra = Assert.Single( + creaturePanel.Children.OfType(), + candidate => candidate.Top == extraHost.Top); + Assert.Equal(3, extra.GetNumUIItems()); + UiTemplateListSlot rating = + Assert.IsType(extra.GetItem(1)); + Assert.Equal( + "Dmg/CritDmg", + Assert.Single(((UiText)rating.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!) + .LinesProvider()).Text); + Assert.Equal( + "Rating: 35/4", + Assert.Single(((UiText)rating.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!) + .LinesProvider()).Text); + Assert.True(extraHost.ZOrder < viewport.ZOrder); + Assert.True(viewport.ZOrder < extra.ZOrder); + } + + [Fact] + public void VisibleExaminationFollowsSelectionAcrossSubviewsAndClosesOnClear() + { + const uint otherObjectId = 0x50000003u; + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Drudge", + Type = ItemType.Creature, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = otherObjectId, + Name = "Sword", + Type = ItemType.MeleeWeapon, + }); + var selection = new SelectionState(); + var sent = new List(); + int closed = 0; + using var interaction = NewInteraction(objects, sent); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => closed++, + selection: selection)!; + + selection.Select(ObjectId, SelectionChangeSource.World); + interaction.ExamineSelectedOrEnterMode(ObjectId); + Assert.True(controller.Apply(Parsed( + new PropertyBundle(), + new AppraiseInfoParser.CreatureProfile( + Flags: 0u, + Health: 1u, + HealthMax: 1u, + Strength: null, + Endurance: null, + Quickness: null, + Coordination: null, + Focus: null, + Self: null, + Stamina: null, + Mana: null, + StaminaMax: null, + ManaMax: null, + AttributeHighlights: null, + AttributeColors: null)))); + controller.OnShown(); + + selection.Select(otherObjectId, SelectionChangeSource.Inventory); + Assert.Equal(new[] { ObjectId, otherObjectId }, sent); + Assert.True(controller.Apply(Parsed( + new PropertyBundle(), + guid: otherObjectId))); + Assert.Equal(AppraisalView.Item, controller.ActiveView); + + selection.Clear(SelectionChangeSource.World); + Assert.Equal(1, closed); + + controller.OnHidden(); + selection.Select(ObjectId, SelectionChangeSource.World); + Assert.Equal(new[] { ObjectId, otherObjectId }, sent); } [Fact] @@ -540,11 +647,13 @@ public sealed class AppraisalUiControllerTests Action show, Action close, CreatureAppraisalRowTemplateFactory? creatureRows = null, - CreatureDisplayNameResolver? creatureNames = null) + CreatureDisplayNameResolver? creatureNames = null, + SelectionState? selection = null) => AppraisalUiController.Bind( layout, objects, interaction, + selection ?? new SelectionState(), combat, new Spellbook(), () => "Tester", @@ -569,9 +678,10 @@ public sealed class AppraisalUiControllerTests private static AppraiseInfoParser.Parsed Parsed( PropertyBundle properties, - AppraiseInfoParser.CreatureProfile? creature = null) + AppraiseInfoParser.CreatureProfile? creature = null, + uint guid = ObjectId) => new( - Guid: ObjectId, + Guid: guid, Flags: creature is null ? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs index 4cee8d62..926573b7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -1,4 +1,6 @@ +using AcDream.App.UI; using AcDream.App.UI.Layout; +using AcDream.Core.Items; using AcDream.Core.Net.Messages; namespace AcDream.App.Tests.UI.Layout; @@ -45,6 +47,53 @@ public sealed class CreatureAppraisalRowsTests Assert.Equal(CreatureAppraisalValueStyle.Normal, rows[1].Style); } + [Fact] + public void ExtraRatingsFollowRetailGroupingFormattingAndSeparators() + { + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; + properties.Ints[0x139u] = 7; + properties.Ints[0x13Au] = 4; + properties.Ints[0x134u] = 12; + properties.Ints[0x13Bu] = 3; + properties.Ints[0x13Cu] = 2; + properties.Ints[0x15Eu] = 9; + properties.Ints[0x15Fu] = 6; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties); + + Assert.Equal(5, rows.Count); + Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); + Assert.Equal( + ("Dmg/CritDmg", "Rating: 35/4"), + (rows[1].Label, rows[1].Value)); + Assert.Equal( + ("Dmg/CritDmg", "Resist: 12/2"), + (rows[2].Label, rows[2].Value)); + Assert.Equal( + ("DoT/Life:", "Resist: 9/6"), + (rows[3].Label, rows[3].Value)); + Assert.Equal(("", ""), (rows[4].Label, rows[4].Value)); + } + + [Fact] + public void CritOnlyTriggersRatingRowWhileHealingBoostAloneDoesNot() + { + var critOnly = new PropertyBundle(); + critOnly.Ints[0x139u] = 8; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(critOnly); + + Assert.Equal(3, rows.Count); + Assert.Equal("Rating: 0/0", rows[1].Value); + + var healingOnly = new PropertyBundle(); + healingOnly.Ints[0x143u] = 20; + Assert.Empty(CreatureAppraisalRows.BuildExtra(healingOnly)); + } + [Fact] public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry() { @@ -76,6 +125,43 @@ public sealed class CreatureAppraisalRowsTests Assert.Equal(string.Empty, resolver.Resolve(999)); } + [Fact] + public void LayeredTemplateSeparatesChromeFromForegroundText() + { + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + _ => (0u, 0, 0), + defaultFont: null); + var row = new CreatureAppraisalRow( + "Strength", + "120", + CreatureAppraisalValueStyle.Normal); + + UiTemplateListSlot background = templates.Create( + row, + CreatureAppraisalRowLayer.Background); + UiTemplateListSlot foreground = templates.Create( + row, + CreatureAppraisalRowLayer.Foreground); + + Assert.Empty(((UiText)background.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!).LinesProvider()); + Assert.Empty(((UiText)background.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!).LinesProvider()); + Assert.False( + Assert.IsType(foreground.Content.Root).MediaVisible); + Assert.Equal( + "Strength", + Assert.Single(((UiText)foreground.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!) + .LinesProvider()).Text); + Assert.Equal( + "120", + Assert.Single(((UiText)foreground.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!) + .LinesProvider()).Text); + } + private static AppraiseInfoParser.CreatureProfile Profile( uint health, uint healthMax,