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
|
||||
|
||||
**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
|
||||
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
|
||||
and the final two-client portal-out/materialization observer flow are
|
||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; the structural closeout plus interaction-completion Slice 1
|
||||
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
|
||||
tests / 5 skips.
|
||||
Release builds; the current interaction-completion baseline passes 3,488 App
|
||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
||||
|
||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||
spell-bar overflow, status Use/Assess, assessment information,
|
||||
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
|
||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -106,22 +106,23 @@ movement queries.
|
|||
|
||||
## 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
|
||||
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
|
||||
and the final two-client portal-out/materialization observer flow are
|
||||
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||
Release builds; the structural closeout plus interaction-completion Slice 1
|
||||
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
|
||||
tests / 5 skips.
|
||||
Release builds; the current interaction-completion baseline passes 3,488 App
|
||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
||||
|
||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||
spell-bar overflow, status Use/Assess, assessment information,
|
||||
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
|
||||
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-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/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-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` |
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ That correction is user-confirmed. The requested adjacent cooldown follow-up
|
|||
ports the optional public shared-cooldown metadata, the authoritative
|
||||
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
||||
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
||||
presentation. Its visual gate comes next; the magnifying-glass Assess command
|
||||
then follows in the same slice.
|
||||
presentation; its connected gate passed. The existing magnifying-glass Assess
|
||||
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
|
||||
adjacent cooldown follow-up now preserves the item's shared group/duration,
|
||||
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
|
||||
DAT-authored radial steps across all item-slot surfaces. Its visual gate is
|
||||
next; the Assess half then continues.
|
||||
DAT-authored radial steps across all item-slot surfaces; its connected gate
|
||||
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:
|
||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# World interaction completion — pre-M4 program
|
||||
|
||||
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
|
||||
zero-useability, carried direct-use, and AutoWear correction passed their
|
||||
connected gates. The requested shared item-cooldown follow-up is code-complete
|
||||
and awaits its visual gate before Assess continues.
|
||||
zero-useability, carried direct-use, AutoWear correction, and the requested
|
||||
shared item-cooldown follow-up passed their connected gates. The existing
|
||||
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.
|
||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||
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
|
||||
pass. AP-123 records only the retained toolkit's procedural-child adaptation;
|
||||
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
|
||||
|
||||
|
|
@ -183,11 +204,40 @@ Named retail references and executable pseudocode are recorded in
|
|||
request owner, and targeted items enter `UseItemOnTarget`, which already
|
||||
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
|
||||
Favor or a summon/stone and verify its inventory and shortcut aliases count
|
||||
down together, then clear.
|
||||
- Port the magnifying-glass Assess command state and activation.
|
||||
- Continue directly into the Assess information slice after the combined
|
||||
status-control gate.
|
||||
- The shared item-cooldown visual gate passed.
|
||||
- `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` confirms the existing
|
||||
magnifying-glass path was already correct: assess the selected GUID
|
||||
immediately, otherwise enter one-shot Examine target mode.
|
||||
- Slice 3 owns the response lifetime and retained examination presentation;
|
||||
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,
|
||||
ItemManaState? ItemMana,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
|
||||
ExternalContainerState? ExternalContainers);
|
||||
ExternalContainerState? ExternalContainers,
|
||||
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
|
||||
|
||||
internal sealed record LiveCharacterSessionBindings(
|
||||
CombatState Combat,
|
||||
|
|
@ -157,6 +158,7 @@ internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
onShortcuts: inventory.OnShortcuts,
|
||||
playerGuid: inventory.PlayerGuid,
|
||||
onUseDone: inventory.OnUseDone,
|
||||
onAppraisal: inventory.OnAppraisal,
|
||||
itemMana: inventory.ItemMana,
|
||||
onConfirmationRequest: character.OnConfirmationRequest,
|
||||
onConfirmationDone: character.OnConfirmationDone,
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
MouseCapture = _interaction.GameplayInput.ResetSession,
|
||||
PlayerPresentation = ResetPlayerPresentation,
|
||||
TeleportTransit = _world.Teleport.ResetSession,
|
||||
SessionDialogs = () => _ui.RetailUi?.ResetSessionDialogs(),
|
||||
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
|
||||
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
|
||||
SettingsCharacterContext =
|
||||
_interaction.Settings.RestoreDefaultCharacterContext,
|
||||
|
|
@ -260,7 +260,14 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.ItemMana,
|
||||
OnDesiredComponents: 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(
|
||||
SkillTable? skillTable)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private uint _consumedPrimaryClickTarget;
|
||||
private long _consumedPrimaryClickMs = long.MinValue / 2;
|
||||
private int _busyCount;
|
||||
private uint _awaitingAppraisalId;
|
||||
private uint _currentAppraisalId;
|
||||
private ulong _nextInventoryRequestToken;
|
||||
private PendingBackpackPlacement? _pendingBackpackPlacement;
|
||||
private PendingInventoryRequest? _pendingInventoryRequest;
|
||||
|
|
@ -222,6 +224,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public InteractionState InteractionState => _interactionState;
|
||||
|
||||
public int BusyCount => _busyCount;
|
||||
public uint CurrentAppraisalId => _currentAppraisalId;
|
||||
|
||||
public bool CanMakeInventoryRequest =>
|
||||
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
|
||||
|
|
@ -451,7 +454,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
ClearTargetMode();
|
||||
accepted = targetGuid != 0 && _sendExamine is not null;
|
||||
if (accepted)
|
||||
_sendExamine!(targetGuid);
|
||||
RequestAppraisal(targetGuid);
|
||||
break;
|
||||
case InteractionModeKind.UseItemOnTarget:
|
||||
accepted = AcquireTarget(targetGuid);
|
||||
|
|
@ -492,7 +495,61 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return _interactionState.EnterExamine();
|
||||
if (_sendExamine is null)
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1240,6 +1297,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_pendingBackpackPlacement = null;
|
||||
_useReservationGeneration++;
|
||||
_busyCount = 0;
|
||||
_awaitingAppraisalId = 0;
|
||||
_currentAppraisalId = 0;
|
||||
_pendingInventoryRequest = null;
|
||||
_lastUseMs = long.MinValue / 2;
|
||||
_consumedPrimaryClickTarget = 0u;
|
||||
|
|
@ -1258,6 +1317,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
failures);
|
||||
}
|
||||
|
||||
public readonly record struct AppraisalResponseAcceptance(
|
||||
bool Accepted,
|
||||
bool FirstResponse);
|
||||
|
||||
private static void DispatchAll(Action? listeners, List<Exception> failures)
|
||||
{
|
||||
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 Magic = 13u;
|
||||
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 =
|
||||
{
|
||||
|
|
@ -28,6 +34,7 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
(Examination, WindowNames.Examination),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountToolbar();
|
||||
MountCombat();
|
||||
MountSpellbook();
|
||||
MountAppraisal();
|
||||
MountEffects();
|
||||
MountIndicatorDetailPanels();
|
||||
MountIndicators();
|
||||
|
|
@ -293,6 +294,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public CombatUiController? CombatUiController { get; private set; }
|
||||
public SpellcastingUiController? SpellcastingUiController { get; private set; }
|
||||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public AppraisalUiController? AppraisalController { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { get; private set; }
|
||||
public EffectsUiController? NegativeEffectsController { get; private set; }
|
||||
public LinkStatusUiController? LinkStatusUiController { get; private set; }
|
||||
|
|
@ -340,6 +342,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
FpsController?.Tick();
|
||||
_vividTargetIndicator?.Tick();
|
||||
SpellbookWindowController?.Tick();
|
||||
AppraisalController?.Tick(deltaSeconds);
|
||||
SpellcastingUiController?.Tick();
|
||||
PositiveEffectsController?.Tick();
|
||||
NegativeEffectsController?.Tick();
|
||||
|
|
@ -391,6 +394,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
|
||||
=> _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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
MountEffectsInstance(positive: true);
|
||||
|
|
|
|||
|
|
@ -21,4 +21,5 @@ public static class WindowNames
|
|||
public const string LinkStatus = "link-status";
|
||||
public const string MiniGame = "mini-game";
|
||||
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.
|
||||
Func<uint>? playerGuid = null,
|
||||
Action<uint /*weenieError*/>? onUseDone = null,
|
||||
Action<AppraiseInfoParser.Parsed>? onAppraisal = null,
|
||||
ItemManaState? itemMana = null,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
|
||||
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
|
||||
|
|
@ -432,10 +433,18 @@ public static class GameEventWiring
|
|||
registrar.Register(GameEventType.IdentifyObjectResponse, e =>
|
||||
{
|
||||
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.
|
||||
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);
|
||||
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
|
||||
// the cast-on-use list. The local player's full learned
|
||||
// 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 & DidStatsTable) packedTable<u32 key, u32 value>
|
||||
/// if (flags & SpellBook) u32 count, u32[count] spellIds
|
||||
/// ...armor/creature/weapon/hook profile blobs follow (deferred)
|
||||
/// ...armor/creature/weapon/hook profile blobs follow
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
|
|
@ -48,10 +48,9 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Returns a fully-populated <see cref="PropertyBundle"/>. Profile
|
||||
/// blobs (ArmorProfile / CreatureProfile / WeaponProfile / HookProfile)
|
||||
/// are recognised via the flag bits but not yet deserialized — the
|
||||
/// parser seeks past them to keep the flag walk aligned.
|
||||
/// Returns the complete gated appraisal payload. A malformed gated field
|
||||
/// rejects the packet rather than returning a partial profile whose cursor
|
||||
/// could be misaligned for every later field.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AppraiseInfoParser
|
||||
|
|
@ -107,6 +106,12 @@ public static class AppraiseInfoParser
|
|||
double WeaponOffense,
|
||||
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>
|
||||
public readonly record struct CreatureProfile(
|
||||
uint Flags,
|
||||
|
|
@ -134,6 +139,7 @@ public static class AppraiseInfoParser
|
|||
ArmorProfile? ArmorProfile,
|
||||
CreatureProfile? CreatureProfile,
|
||||
WeaponProfile? WeaponProfile,
|
||||
HookProfile? HookProfile,
|
||||
ArmorLevel? ArmorLevels,
|
||||
(ushort Highlight, ushort Color)? ArmorEnchantments,
|
||||
(ushort Highlight, ushort Color)? WeaponEnchantments,
|
||||
|
|
@ -160,6 +166,7 @@ public static class AppraiseInfoParser
|
|||
ArmorProfile? armor = null;
|
||||
CreatureProfile? creature = null;
|
||||
WeaponProfile? weapon = null;
|
||||
HookProfile? hook = null;
|
||||
ArmorLevel? levels = null;
|
||||
(ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null;
|
||||
|
||||
|
|
@ -187,7 +194,8 @@ public static class AppraiseInfoParser
|
|||
creature = ReadCreatureProfile(payload, ref pos);
|
||||
if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile))
|
||||
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))
|
||||
armorEnc = ReadEnchantmentBitfield(payload, ref pos);
|
||||
if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield))
|
||||
|
|
@ -199,12 +207,12 @@ public static class AppraiseInfoParser
|
|||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed table — return what we got so far.
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Parsed(
|
||||
guid, flags, success != 0, bundle, spellBook,
|
||||
armor, creature, weapon, levels,
|
||||
armor, creature, weapon, hook, levels,
|
||||
armorEnc, weaponEnc, resistEnc);
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +260,12 @@ public static class AppraiseInfoParser
|
|||
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)
|
||||
{
|
||||
uint flags = ReadU32(src, ref pos);
|
||||
|
|
|
|||
|
|
@ -301,15 +301,54 @@ public sealed class ItemInteractionControllerTests
|
|||
|
||||
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
|
||||
Assert.Equal(new[] { Player }, h.Examines);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
|
||||
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
|
||||
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
|
||||
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
|
||||
h.Controller.OfferPrimaryClick(Pack));
|
||||
Assert.Equal(new[] { Player, Pack }, h.Examines);
|
||||
Assert.Equal(1, h.Controller.BusyCount);
|
||||
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]
|
||||
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()
|
||||
=> 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()
|
||||
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,29 @@ public sealed class RetailFixtureConformanceTests
|
|||
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
|
||||
=> new()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"effects_row_2100001B_10000128.json"),
|
||||
(CharacterController.LayoutId, CharacterController.RootId,
|
||||
"character_info_2100006E_10000183.json"),
|
||||
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
|
||||
})
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
||||
|
|
|
|||
|
|
@ -183,6 +183,19 @@ public sealed class AppraiseInfoParserTests
|
|||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
|
@ -322,4 +335,31 @@ public sealed class AppraiseInfoParserTests
|
|||
Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight);
|
||||
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