feat(ui): port retail appraisal panel

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

View file

@ -108,22 +108,23 @@ movement queries.
## Current state ## Current state
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail **M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world ACTIVE.** Retail
casting, spellbook/component-book UI, active effects, recall/portal-space casting, spellbook/component-book UI, active effects, recall/portal-space
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline, presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
and the final two-client portal-out/materialization observer flow are and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1 Release builds; the current interaction-completion baseline passes 3,488 App
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution tests / 3 skips and 7,885 complete-solution tests / 5 skips.
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information, spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate. transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's retail examination request/panel implementation is
code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -106,22 +106,23 @@ movement queries.
## Current state ## Current state
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail **M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world ACTIVE.** Retail
casting, spellbook/component-book UI, active effects, recall/portal-space casting, spellbook/component-book UI, active effects, recall/portal-space
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline, presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
and the final two-client portal-out/materialization observer flow are and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login, user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1 Release builds; the current interaction-completion baseline passes 3,488 App
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution tests / 3 skips and 7,885 complete-solution tests / 5 skips.
tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion **Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information, spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate. transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's retail examination request/panel implementation is
code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight **Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -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-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-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-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/examine/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 | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat plus M3 spellbook/component/effects/favorite-spell/Link/Vitae surfaces now cover the active melee/missile/magic loops; the Link page has exact ping RTT but reports the current transport's zero default until its missing reliability statistics land | Large portions of retail gameplay still have no production UI; real packet loss is displayed as 0.00% instead of retail's moving average | Named `gm*UI::PostInit` methods; `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 now has its authored outer layout and basic item/creature/character reports, but still lacks the exact 3D preview and exhaustive specialized detail regions, conditional inscription-write transaction, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.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 loop cover the active loops; the remaining examination mechanisms require additional render and item-write owners rather than fabricated content | Specialized assessments omit some retail fields/3D preview and inscriptions are display-only; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `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-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-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` | | 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` |

View file

@ -29,8 +29,13 @@ That correction is user-confirmed. The requested adjacent cooldown follow-up
ports the optional public shared-cooldown metadata, the authoritative ports the optional public shared-cooldown metadata, the authoritative
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot` sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
presentation. Its visual gate comes next; the magnifying-glass Assess command presentation; its connected gate passed. The existing magnifying-glass Assess
then follows in the same slice. command was already retail-faithful. Slice 3 is now code-complete: the complete
appraisal response reaches a balanced pending/current request owner and the
imported 310 x 400 examination layout through the canonical shared main-panel
host. Item, creature, and character views, authored scrollbars, first-response
visibility, and visible-combat refresh are pinned; the connected examination
gate is next.
--- ---

View file

@ -96,8 +96,12 @@ The follow-up retail correction for zero-valued direct-use items, named
AutoWear blockers, and carried-use delivery is user-confirmed. The requested AutoWear blockers, and carried-use delivery is user-confirmed. The requested
adjacent cooldown follow-up now preserves the item's shared group/duration, adjacent cooldown follow-up now preserves the item's shared group/duration,
projects the authoritative cooldown-enchantment bucket, and draws retail's ten projects the authoritative cooldown-enchantment bucket, and draws retail's ten
DAT-authored radial steps across all item-slot surfaces. Its visual gate is DAT-authored radial steps across all item-slot surfaces; its connected gate
next; the Assess half then continues. passed. The pre-existing Assess command has been reconciled against retail and
Slice 3 is code-complete: strict full appraisal parsing, balanced
pending/current request ownership, and LayoutDesc `0x2100006B` item,
creature, and character views now live in the canonical shared main-panel
host. Its connected examination gate is next.
Carried: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -1,9 +1,10 @@
# World interaction completion — pre-M4 program # World interaction completion — pre-M4 program
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection, **Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
zero-useability, carried direct-use, and AutoWear correction passed their zero-useability, carried direct-use, AutoWear correction, and the requested
connected gates. The requested shared item-cooldown follow-up is code-complete shared item-cooldown follow-up passed their connected gates. The existing
and awaits its visual gate before Assess continues. retail Assess command has been reconciled and Slice 3's examination panel is
code-complete; its connected visual gate is next.
**Milestone:** M4 prerequisite/preamble. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. interaction state. `GameWindow` remains a composition/callback shell.
@ -106,6 +107,26 @@ The focused parser/Core/UI/production-DAT tests, warning-free Release solution
build, 3,482 App tests / 3 skips, and 7,875 complete-solution tests / 5 skips build, 3,482 App tests / 3 skips, and 7,875 complete-solution tests / 5 skips
pass. AP-123 records only the retained toolkit's procedural-child adaptation; pass. AP-123 records only the retained toolkit's procedural-child adaptation;
the visible assets, ordering, timing, and shared-group behavior are exact. the visible assets, ordering, timing, and shared-group behavior are exact.
The user accepted the live cooldown presentation 2026-07-23.
Slice 3 preserves the existing toolbar/keyboard Assess command and ports the
retail response owner around it. One shared UI-busy reference covers the
latest pending GUID, stale replies are rejected, the accepted reply becomes
the current appraisal, and combat-time creature/player refreshes occur every
0.75 seconds only while the examination window is visible. Complete
`IdentifyObjectResponse` parsing now includes the three-word HookProfile and
rejects truncated gated payloads rather than continuing from a corrupt cursor.
LayoutDesc `0x2100006B`, root `0x100005F2`, supplies the complete 310 x 400
floaty chrome, title, item/creature/character subviews, close control, and
authored scrollbars. The panel participates in the canonical one-active
main-panel geometry/lifecycle rather than owning a parallel location. Basic
item, creature, and player reports are live; exact 3D preview/specialized
detail regions, conditional inscription editing, and selection-follow 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,488 App
tests / 3 skips, and 7,885 complete-solution tests / 5 skips.
## Slice 1 — spell-bar overflow arrows ## Slice 1 — spell-bar overflow arrows
@ -183,11 +204,40 @@ Named retail references and executable pseudocode are recorded in
request owner, and targeted items enter `UseItemOnTarget`, which already request owner, and targeted items enter `UseItemOnTarget`, which already
owns the retail target cursor. owns the retail target cursor.
### Remaining Slice 2 work ### Slice 2 closeout
- Run the connected visual gate for the shared item cooldown: use Blackmoor's - The shared item-cooldown visual gate passed.
Favor or a summon/stone and verify its inventory and shortcut aliases count - `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` confirms the existing
down together, then clear. magnifying-glass path was already correct: assess the selected GUID
- Port the magnifying-glass Assess command state and activation. immediately, otherwise enter one-shot Examine target mode.
- Continue directly into the Assess information slice after the combined - Slice 3 owns the response lifetime and retained examination presentation;
status-control gate. Slice 2 adds no duplicate command path.
## Slice 3 — assessment information panel
### Implemented ownership
1. `AppraiseInfoParser` owns the complete positional response payload,
including HookProfile and strict truncation failure.
2. `ItemInteractionController` owns the pending/current GUID pair and the
balanced shared busy reference.
3. `AppraisalUiController` owns response acceptance, item/creature/character
subview selection, report projection, scroll preservation, first-response
visibility, and visible-combat refresh.
4. `RetailUiRuntime` imports the retail layout and registers it with
`RetailPanelUiController`, so Inventory, Skills, Spellbook, effects, and
Examination remain views of the same movable/resizable main panel.
5. Network workers deliver immutable parsed data through the existing
session router; retained state changes remain on the update thread.
### Connected gate
- Select an item and click the magnifying glass (or press Assess): the shared
panel should show its name, available properties, descriptions,
inscription/signature, retail chrome, and working scrollbar.
- Assess a monster, NPC, and player: the correct creature/character page
should appear and the busy cursor should clear.
- In combat, leave a creature assessment open long enough to observe a health
refresh; closing it or opening another shared panel must stop refreshes.
- Move/resize Examination, switch to Inventory or Skills, and confirm the
shared rectangle is retained.

View file

@ -0,0 +1,193 @@
# Retail appraisal request and examination UI pseudocode
**Date:** 2026-07-23
**Scope:** status-bar Assess request, IdentifyObjectResponse parsing, and the
retained examination window.
## Sources
- Named retail:
- `gmToolbarUI::ListenToElementMessage @ 0x004BEE90`
- `gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0`
- `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`
- `gmExaminationUI::SetActiveExamineUI @ 0x004AB420`
- `gmExaminationUI::UseTime @ 0x004AB530`
- `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`
- `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70`
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
- `gmFloatyExaminationUI::ResizeTo @ 0x004D4470`
- Retail LayoutDesc `0x2100006B`, root `0x100005F2`
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
- ACE:
- `GameEventIdentifyObjectResponse`
- `AppraiseInfo.Write`
- `HookProfile.Write`
- ACViewer's `HookAppraisalProfile` representation, used as a second
interpretation check for the three-field hook profile.
The named retail client is the behavior oracle. ACE and ACViewer only clarify
the server packet and structure interpretation.
## Toolbar request
```text
on Examine button:
if selected object id != 0:
ClientUISystem.ExamineObject(selected id)
else:
enter TARGET_MODE_EXAMINE
```
This was already present in acdream before this slice and does not need a
second command path.
## Examination request lifetime
```text
on ExamineObject(guid):
if guid == 0:
return
if awaitingAppraisalId == 0:
ClientUISystem.IncrementBusyCount()
awaitingAppraisalId = guid
send CM_Item.Event_Appraise(guid)
clear pending profile pointer
```
Replacing one pending GUID does not add a second busy reference. The response
for the current pending GUID releases that one reference.
## IdentifyObjectResponse packet order
```text
read guid
read flags
read success
if IntStatsTable: read packed int table
if Int64StatsTable: read packed int64 table
if BoolStatsTable: read packed bool table
if FloatStatsTable: read packed double table
if StringStatsTable: read packed string table
if DidStatsTable: read packed DID table
if SpellBook: read spell list
if ArmorProfile: read armor profile
if CreatureProfile: read creature profile
if WeaponProfile: read weapon profile
if HookProfile: read flags, validLocations, ammoType (three u32)
if ArmorEnchantmentBitfield: read highlight/color (two u16)
if WeaponEnchantmentBitfield: read highlight/color (two u16)
if ResistEnchantmentBitfield: read highlight/color (two u16)
if ArmorLevels: read nine u32 armor levels
```
Every gated field is positional. A truncated gated field rejects the packet;
returning a partial profile would let later fields masquerade as earlier ones.
## Applying an appraisal
```text
SetAppraiseInfo(guid, profile):
if guid == 0:
return
if guid != awaitingAppraisalId and guid != currentAppraisalId:
return
firstResponse = false
if guid == awaitingAppraisalId:
awaitingAppraisalId = 0
ClientUISystem.DecrementBusyCount()
currentAppraisalId = guid
firstResponse = true
if profile has no CreatureProfile:
subview = item
else if profile has string property Template (5)
or int property EquipmentSetId (0x105):
subview = character/player
else:
subview = creature
object = ClientObjMaintSystem.GetWeenieObject(guid)
if object is absent:
return
titleName = profile string 0x34 if present
else object.GetObjectName(NAME_APPROPRIATE)
title = titleName with stack count
newlySelectedInSubview = guid != subview.currentObjectId
subview.Init(guid, object)
subview.SetAppraiseInfo(profile, newlySelectedInSubview)
SetActiveExamineUI(subview)
if firstResponse:
show examination window
```
Only the first response to an explicit request forces the window visible.
Background refreshes update the current object without reopening a closed
window.
## Subview and refresh behavior
```text
SetActiveExamineUI(selected):
hide item, creature, character, and spell subviews
show selected subview only
on time pulse:
if examination window is visible
and combat mode is not peace
and active subview is creature or character
and currentAppraisalId != 0
and 0.75 seconds elapsed:
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.
## Item presentation order
`ItemExamineUI::SetAppraiseInfo` clears and rebuilds the authored item text in
this order:
1. icon;
2. value and burden (the EoR layout has no separate value/burden controls, so
these enter the main text);
3. inscription/signature;
4. tinkering/workmanship;
5. set and rating information;
6. weapon or armor profile;
7. defense and armor modifiers;
8. magic, special properties, usage and wield requirements;
9. caster/boost/healer/capacity/lock/mana/uses/craftsman details;
10. sale restriction, rare status, full magic, and description.
The main text and inscription areas use their authored scrollbars. A newly
selected object resets those scroll positions to the top; a refresh of the
same object preserves the user's position.
## Window behavior
The LayoutDesc root already contains retail's complete floaty window chrome,
close control, item/creature/character/spell subviews, and scrollbars.
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
and persists width/height; it adds no special resize algorithm.
## Slice boundary
This slice ports the request lifetime, packet integrity, subview choice,
authored outer layout, basic report content, scrollbar ownership, and combat
refresh. Retail's exact 3D object/creature preview, all specialized
`ItemExamineUI` detail branches, selection-follow preference, and
`SetInscriptionEditableState` plus inscription submission remain explicit
AP-110 work. Until that permission/write transaction exists, the imported
inscription field is display-only rather than accepting edits that cannot be
sent.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -73,6 +73,7 @@ public static class GameEventWiring
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null, Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null, Action<uint /*weenieError*/>? onUseDone = null,
Action<AppraiseInfoParser.Parsed>? onAppraisal = null,
ItemManaState? itemMana = null, ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null, Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null, Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
@ -432,10 +433,18 @@ public static class GameEventWiring
registrar.Register(GameEventType.IdentifyObjectResponse, e => registrar.Register(GameEventType.IdentifyObjectResponse, e =>
{ {
var p = AppraiseInfoParser.TryParse(e.Payload.Span); var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return; if (p is null) return;
// Merge parsed properties into the item if we know about it. // Merge parsed properties into the item if we know about it.
if (items.Get(p.Value.Guid) is not null) if (p.Value.Success && items.Get(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties); items.UpdateProperties(p.Value.Guid, p.Value.Properties);
if (p.Value.CreatureProfile is { HealthMax: > 0u } creature)
combat.OnUpdateHealth(
p.Value.Guid,
Math.Clamp(
(float)creature.Health / creature.HealthMax,
0f,
1f));
onAppraisal?.Invoke(p.Value);
// Spellbook from appraise: for caster items / scrolls this is // Spellbook from appraise: for caster items / scrolls this is
// the cast-on-use list. The local player's full learned // the cast-on-use list. The local player's full learned
// spellbook arrives via PlayerDescription (0x0013), which uses // spellbook arrives via PlayerDescription (0x0013), which uses

View file

@ -34,7 +34,7 @@ namespace AcDream.Core.Net.Messages;
/// if (flags &amp; StringStatsTable) packedTable&lt;u32 key, string16L value&gt; /// if (flags &amp; StringStatsTable) packedTable&lt;u32 key, string16L value&gt;
/// if (flags &amp; DidStatsTable) packedTable&lt;u32 key, u32 value&gt; /// if (flags &amp; DidStatsTable) packedTable&lt;u32 key, u32 value&gt;
/// if (flags &amp; SpellBook) u32 count, u32[count] spellIds /// if (flags &amp; SpellBook) u32 count, u32[count] spellIds
/// ...armor/creature/weapon/hook profile blobs follow (deferred) /// ...armor/creature/weapon/hook profile blobs follow
/// </code> /// </code>
/// </para> /// </para>
/// ///
@ -48,10 +48,9 @@ namespace AcDream.Core.Net.Messages;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// Returns a fully-populated <see cref="PropertyBundle"/>. Profile /// Returns the complete gated appraisal payload. A malformed gated field
/// blobs (ArmorProfile / CreatureProfile / WeaponProfile / HookProfile) /// rejects the packet rather than returning a partial profile whose cursor
/// are recognised via the flag bits but not yet deserialized — the /// could be misaligned for every later field.
/// parser seeks past them to keep the flag walk aligned.
/// </para> /// </para>
/// </summary> /// </summary>
public static class AppraiseInfoParser public static class AppraiseInfoParser
@ -107,6 +106,12 @@ public static class AppraiseInfoParser
double WeaponOffense, double WeaponOffense,
uint MaxVelocityEstimated); uint MaxVelocityEstimated);
/// <summary>Hook-container item metadata serialized as three consecutive u32 values.</summary>
public readonly record struct HookProfile(
uint Flags,
uint ValidLocations,
uint AmmoType);
/// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary> /// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary>
public readonly record struct CreatureProfile( public readonly record struct CreatureProfile(
uint Flags, uint Flags,
@ -134,6 +139,7 @@ public static class AppraiseInfoParser
ArmorProfile? ArmorProfile, ArmorProfile? ArmorProfile,
CreatureProfile? CreatureProfile, CreatureProfile? CreatureProfile,
WeaponProfile? WeaponProfile, WeaponProfile? WeaponProfile,
HookProfile? HookProfile,
ArmorLevel? ArmorLevels, ArmorLevel? ArmorLevels,
(ushort Highlight, ushort Color)? ArmorEnchantments, (ushort Highlight, ushort Color)? ArmorEnchantments,
(ushort Highlight, ushort Color)? WeaponEnchantments, (ushort Highlight, ushort Color)? WeaponEnchantments,
@ -160,6 +166,7 @@ public static class AppraiseInfoParser
ArmorProfile? armor = null; ArmorProfile? armor = null;
CreatureProfile? creature = null; CreatureProfile? creature = null;
WeaponProfile? weapon = null; WeaponProfile? weapon = null;
HookProfile? hook = null;
ArmorLevel? levels = null; ArmorLevel? levels = null;
(ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null; (ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null;
@ -187,7 +194,8 @@ public static class AppraiseInfoParser
creature = ReadCreatureProfile(payload, ref pos); creature = ReadCreatureProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile)) if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile))
weapon = ReadWeaponProfile(payload, ref pos); weapon = ReadWeaponProfile(payload, ref pos);
// HookProfile (0x200) — not deserialized; would need HookProfile struct port. if (flags.HasFlag(IdentifyResponseFlags.HookProfile))
hook = ReadHookProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.ArmorEnchantmentBitfield)) if (flags.HasFlag(IdentifyResponseFlags.ArmorEnchantmentBitfield))
armorEnc = ReadEnchantmentBitfield(payload, ref pos); armorEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield)) if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield))
@ -199,12 +207,12 @@ public static class AppraiseInfoParser
} }
catch (FormatException) catch (FormatException)
{ {
// Malformed table — return what we got so far. return null;
} }
return new Parsed( return new Parsed(
guid, flags, success != 0, bundle, spellBook, guid, flags, success != 0, bundle, spellBook,
armor, creature, weapon, levels, armor, creature, weapon, hook, levels,
armorEnc, weaponEnc, resistEnc); armorEnc, weaponEnc, resistEnc);
} }
@ -252,6 +260,12 @@ public static class AppraiseInfoParser
MaxVelocityEstimated: ReadU32(src, ref pos)); MaxVelocityEstimated: ReadU32(src, ref pos));
} }
private static HookProfile ReadHookProfile(ReadOnlySpan<byte> src, ref int pos)
=> new(
Flags: ReadU32(src, ref pos),
ValidLocations: ReadU32(src, ref pos),
AmmoType: ReadU32(src, ref pos));
private static CreatureProfile ReadCreatureProfile(ReadOnlySpan<byte> src, ref int pos) private static CreatureProfile ReadCreatureProfile(ReadOnlySpan<byte> src, ref int pos)
{ {
uint flags = ReadU32(src, ref pos); uint flags = ReadU32(src, ref pos);

View file

@ -301,15 +301,54 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player)); Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
Assert.Equal(new[] { Player }, h.Examines); Assert.Equal(new[] { Player }, h.Examines);
Assert.Equal(1, h.Controller.BusyCount);
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u)); Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind); Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess, Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
h.Controller.OfferPrimaryClick(Pack)); h.Controller.OfferPrimaryClick(Pack));
Assert.Equal(new[] { Player, Pack }, h.Examines); Assert.Equal(new[] { Player, Pack }, h.Examines);
Assert.Equal(1, h.Controller.BusyCount);
Assert.False(h.Controller.IsAnyTargetModeActive); Assert.False(h.Controller.IsAnyTargetModeActive);
} }
[Fact]
public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh()
{
var h = new Harness();
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
Assert.Equal(1, h.Controller.BusyCount);
Assert.False(h.Controller.AcceptAppraisalResponse(Player).Accepted);
Assert.Equal(1, h.Controller.BusyCount);
var first = h.Controller.AcceptAppraisalResponse(Pack);
Assert.True(first.Accepted);
Assert.True(first.FirstResponse);
Assert.Equal(0, h.Controller.BusyCount);
Assert.Equal(Pack, h.Controller.CurrentAppraisalId);
var refresh = h.Controller.AcceptAppraisalResponse(Pack);
Assert.True(refresh.Accepted);
Assert.False(refresh.FirstResponse);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void RefreshCurrentAppraisal_sendsWithoutAcquiringBusyReference()
{
var h = new Harness();
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
Assert.True(h.Controller.AcceptAppraisalResponse(Pack).Accepted);
Assert.True(h.Controller.RefreshCurrentAppraisal());
Assert.Equal(new[] { Pack, Pack }, h.Examines);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact] [Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode() public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{ {

View file

@ -0,0 +1,192 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class AppraisalUiControllerTests
{
private const uint ObjectId = 0x50000001u;
[Fact]
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Atlan Weapon",
Type = ItemType.MeleeWeapon,
Value = 1250,
Burden = 350,
});
var sent = new List<uint>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
int closed = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => shown++,
() => closed++)!;
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
var properties = new PropertyBundle();
properties.Strings[16u] = "A finely balanced weapon.";
properties.Strings[7u] = "Remember the fallen.";
properties.Strings[8u] = "Tester";
Assert.True(controller.Apply(Parsed(properties)));
Assert.Equal(1, shown);
Assert.Equal(AppraisalView.Item, controller.ActiveView);
Assert.Equal(0, interaction.BusyCount);
UiText title = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.TitleId));
Assert.Equal("Atlan Weapon", Assert.Single(title.LinesProvider()).Text);
UiText itemText = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.ItemTextId));
string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text));
Assert.Contains("Value: 1,250", report);
Assert.Contains("Burden: 350", report);
Assert.Contains("A finely balanced weapon.", report);
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(AppraisalUiController.ItemScrollbarId));
Assert.Same(itemText.Scroll, scrollbar.Model);
UiField inscription = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.Contains("Remember the fallen", inscription.Text);
Assert.False(inscription.Enabled);
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.Equal("Inscribed by Tester", Assert.Single(signature.LinesProvider()).Text);
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
Assert.Equal(1, closed);
}
[Fact]
public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Drudge Slinker",
Type = ItemType.Creature,
});
var sent = new List<uint>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => shown++,
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[25u] = 12;
var creature = new AppraiseInfoParser.CreatureProfile(
Flags: 0,
Health: 80,
HealthMax: 100,
Strength: null,
Endurance: null,
Quickness: null,
Coordination: null,
Focus: null,
Self: null,
Stamina: null,
Mana: null,
StaminaMax: null,
ManaMax: null,
AttributeHighlights: null,
AttributeColors: null);
Assert.True(controller.Apply(Parsed(properties, creature)));
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
Assert.Equal(1, shown);
Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
Assert.True(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
combat.SetCombatMode(CombatMode.Melee);
controller.Tick(0.74);
Assert.Single(sent);
controller.Tick(0.01);
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
Assert.Equal(0, interaction.BusyCount);
layout.Root.Visible = false;
controller.Tick(0.75);
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
Assert.True(controller.Apply(Parsed(properties, creature)));
Assert.Equal(1, shown);
}
[Fact]
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
new CombatState(),
new Spellbook(),
() => throw new InvalidOperationException("must not show"),
() => { })!;
Assert.False(controller.Apply(Parsed(new PropertyBundle())));
Assert.Equal(0u, controller.CurrentObjectId);
}
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
List<uint> sent)
=> new(
objects,
playerGuid: () => 0x50000002u,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: sent.Add);
private static AppraiseInfoParser.Parsed Parsed(
PropertyBundle properties,
AppraiseInfoParser.CreatureProfile? creature = null)
=> new(
Guid: ObjectId,
Flags: creature is null
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
Success: true,
Properties: properties,
SpellBook: [],
ArmorProfile: null,
CreatureProfile: creature,
WeaponProfile: null,
HookProfile: null,
ArmorLevels: null,
ArmorEnchantments: null,
WeaponEnchantments: null,
ResistEnchantments: null);
}

View file

@ -107,6 +107,12 @@ public static class FixtureLoader
public static ElementInfo LoadComponentRowTemplateInfos() public static ElementInfo LoadComponentRowTemplateInfos()
=> LoadInfos("component_row_21000033_10000467.json"); => LoadInfos("component_row_21000033_10000467.json");
public static ImportedLayout LoadExamination()
=> LayoutImporter.Build(LoadExaminationInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadExaminationInfos()
=> LoadInfos("examine_2100006B_100005F2.json");
public static ImportedLayout LoadPowerbar() public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null); => LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -68,6 +68,29 @@ public sealed class RetailFixtureConformanceTests
Assert.NotNull(Find(root, CharacterStatController.TitlesPageId)); Assert.NotNull(Find(root, CharacterStatController.TitlesPageId));
} }
[Fact]
public void ExaminationFixture_ContainsRetailChromeSubviewsAndScrollbars()
{
ElementInfo root = FixtureLoader.LoadExaminationInfos();
Assert.Equal(AppraisalUiController.RootId, root.Id);
Assert.Equal(310f, root.Width);
Assert.Equal(400f, root.Height);
Assert.NotNull(Find(root, AppraisalUiController.CloseId));
Assert.NotNull(Find(root, AppraisalUiController.TitleId));
Assert.NotNull(Find(root, AppraisalUiController.ItemPanelId));
Assert.NotNull(Find(root, AppraisalUiController.ItemTextId));
Assert.NotNull(Find(root, AppraisalUiController.ItemScrollbarId));
Assert.NotNull(Find(root, AppraisalUiController.CreaturePanelId));
Assert.NotNull(Find(root, AppraisalUiController.SpellPanelId));
ImportedLayout layout = FixtureLoader.LoadExamination();
Assert.IsType<UiButton>(layout.FindElement(AppraisalUiController.CloseId));
Assert.IsType<UiText>(layout.FindElement(AppraisalUiController.TitleId));
Assert.IsType<UiScrollbar>(
layout.FindElement(AppraisalUiController.ItemScrollbarId));
}
public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases
=> new() => new()
{ {

View file

@ -131,6 +131,7 @@ public sealed class RetailLayoutFixtureGenerator
"effects_row_2100001B_10000128.json"), "effects_row_2100001B_10000128.json"),
(CharacterController.LayoutId, CharacterController.RootId, (CharacterController.LayoutId, CharacterController.RootId,
"character_info_2100006E_10000183.json"), "character_info_2100006E_10000183.json"),
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
}) })
{ {
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId); ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);

File diff suppressed because it is too large Load diff

View file

@ -49,6 +49,50 @@ public sealed class GameEventWiringTests
return (dispatcher, items, combat, spellbook, chat); return (dispatcher, items, combat, spellbook, chat);
} }
[Theory]
[InlineData(false)]
[InlineData(true)]
public void WireAll_IdentifyObjectResponse_ForwardsCompleteResultAndMergesOnlySuccess(
bool success)
{
const uint guid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
items.UpsertProperties(guid, new PropertyBundle());
AppraiseInfoParser.Parsed? received = null;
GameEventWiring.WireAll(
dispatcher,
items,
new CombatState(),
new Spellbook(),
new ChatLog(),
onAppraisal: appraisal => received = appraisal);
byte[] payload = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
BinaryPrimitives.WriteUInt32LittleEndian(
payload.AsSpan(4),
(uint)AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), success ? 1u : 0u);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(12), 1);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(14), 16);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 25u);
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(20), 42);
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload));
Assert.NotNull(envelope);
dispatcher.Dispatch(envelope.Value);
Assert.NotNull(received);
Assert.Equal(success, received.Value.Success);
Assert.Equal(42, received.Value.Properties.Ints[25u]);
if (success)
Assert.Equal(42, items.Get(guid)!.Properties.Ints[25u]);
else
Assert.False(items.Get(guid)!.Properties.Ints.ContainsKey(25u));
}
[Fact] [Fact]
public void WireAll_ChannelBroadcast_RoutesToChatLog() public void WireAll_ChannelBroadcast_RoutesToChatLog()

View file

@ -183,6 +183,19 @@ public sealed class AppraiseInfoParserTests
Assert.Null(AppraiseInfoParser.TryParse(new byte[4])); Assert.Null(AppraiseInfoParser.TryParse(new byte[4]));
} }
[Fact]
public void TryParse_TruncatedGatedField_RejectsEntirePacket()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.HookProfile);
bw.Write(1u);
bw.Write(0x3u);
Assert.Null(AppraiseInfoParser.TryParse(ms.ToArray()));
}
// ── Profile blobs ──────────────────────────────────────────────────────── // ── Profile blobs ────────────────────────────────────────────────────────
[Fact] [Fact]
@ -322,4 +335,31 @@ public sealed class AppraiseInfoParserTests
Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight); Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight);
Assert.Equal((ushort)0x0042, parsed.Value.ArmorEnchantments.Value.Color); Assert.Equal((ushort)0x0042, parsed.Value.ArmorEnchantments.Value.Color);
} }
[Fact]
public void TryParse_HookProfile_PreservesAlignmentForFollowingFields()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write((uint)(
AppraiseInfoParser.IdentifyResponseFlags.HookProfile
| AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield));
bw.Write(1u);
bw.Write(0x0000000Bu);
bw.Write(0x00100000u);
bw.Write(0x00000004u);
bw.Write((ushort)0x1234);
bw.Write((ushort)0x5678);
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed);
Assert.Equal(
new AppraiseInfoParser.HookProfile(0xBu, 0x00100000u, 4u),
parsed.Value.HookProfile);
Assert.Equal(
((ushort)0x1234, (ushort)0x5678),
parsed.Value.ArmorEnchantments);
}
} }