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:
parent
6b1ae4fb76
commit
643cdfe66e
24 changed files with 17132 additions and 40 deletions
11
AGENTS.md
11
AGENTS.md
|
|
@ -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
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
193
docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md
Normal file
193
docs/research/2026-07-23-retail-appraisal-ui-pseudocode.md
Normal 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.
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
712
src/AcDream.App/UI/Layout/AppraisalUiController.cs
Normal file
712
src/AcDream.App/UI/Layout/AppraisalUiController.cs
Normal 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)}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ namespace AcDream.Core.Net.Messages;
|
||||||
/// if (flags & StringStatsTable) packedTable<u32 key, string16L value>
|
/// if (flags & StringStatsTable) packedTable<u32 key, string16L value>
|
||||||
/// if (flags & DidStatsTable) packedTable<u32 key, u32 value>
|
/// if (flags & DidStatsTable) packedTable<u32 key, u32 value>
|
||||||
/// if (flags & SpellBook) u32 count, u32[count] spellIds
|
/// if (flags & 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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
192
tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
Normal file
192
tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue