fix #234: port retail appraisal floaty and inscriptions
This commit is contained in:
parent
643cdfe66e
commit
f1a7912160
23 changed files with 1310 additions and 106 deletions
10
AGENTS.md
10
AGENTS.md
|
|
@ -115,16 +115,18 @@ 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 current interaction-completion baseline passes 3,488 App
|
||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
||||
Release builds; the appraisal corrective implementation passes 3,496 App
|
||||
tests / 3 skips and 7,913 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. 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.
|
||||
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||
inscription transaction. Its independent floaty examination window awaits the
|
||||
corrective connected gate.
|
||||
|
||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -113,16 +113,18 @@ 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 current interaction-completion baseline passes 3,488 App
|
||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
||||
Release builds; the appraisal corrective implementation passes 3,496 App
|
||||
tests / 3 skips and 7,913 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. 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.
|
||||
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||
monster-response flag mismatch, non-retail shared-panel mounting, and missing
|
||||
inscription transaction. Its independent floaty examination window awaits the
|
||||
corrective connected gate.
|
||||
|
||||
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||
|
|
|
|||
|
|
@ -74,6 +74,44 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #234 — Monster assessment held busy and examination used the shared panel
|
||||
|
||||
**Status:** DONE — 2026-07-23, corrective connected gate pending
|
||||
**Severity:** HIGH
|
||||
**Filed:** 2026-07-23
|
||||
**Component:** appraisal protocol / retained examination UI / item writing
|
||||
|
||||
**Description:** Assessing a monster appeared to hang the UI with the busy
|
||||
cursor. Examination also replaced Inventory/Skills in the shared primary
|
||||
panel, while retail opens its own floaty window. Assessed item inscriptions
|
||||
were display-only.
|
||||
|
||||
**Root cause / status:** The local IdentifyResponse enum did not match
|
||||
ACE/retail: creature profile bit `0x0100` was labeled WeaponProfile. The strict
|
||||
parser consequently rejected normal creature responses before the appraisal
|
||||
owner could release its balanced busy reference. The examination LayoutDesc
|
||||
had also been incorrectly registered as synthetic main-panel id
|
||||
`0x80000001`; retail's concrete root is `gmFloatyExaminationUI`. Exact response
|
||||
bits/order are now pinned by literal packet tests, the floaty is registered as
|
||||
an independent top-level window, and the imported multiline field sends
|
||||
retail's CP-1252 `SetInscription (0x00BF)` under its ownership/authorship rules.
|
||||
|
||||
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
|
||||
`src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||
`src/AcDream.App/UI/Layout/AppraisalUiController.cs`;
|
||||
`src/AcDream.App/UI/UiField.cs`;
|
||||
`src/AcDream.Core.Net/Messages/InventoryActions.cs`.
|
||||
|
||||
**Research:**
|
||||
[`research/2026-07-23-retail-appraisal-ui-pseudocode.md`](research/2026-07-23-retail-appraisal-ui-pseudocode.md).
|
||||
|
||||
**Acceptance:** Monster/NPC/player assessment opens and clears busy; the
|
||||
examination window coexists with the shared primary panel; an owned
|
||||
inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/
|
||||
noninscribable cases show retail's exact permission messages.
|
||||
|
||||
---
|
||||
|
||||
## #235 — Capped/RDP jump presentation aliases the 30 Hz object clock
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination 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-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 independent authored floaty layout, item/creature/character reports, and conditional inscription-write transaction, but still lacks the exact 3D preview, exhaustive specialized detail regions, 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/inscription loop cover the active loops; the remaining examination mechanisms require the retail preview render owner and additional field projections rather than fabricated content | Specialized assessments omit some retail fields/3D preview; 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`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
||||
|
|
|
|||
|
|
@ -30,11 +30,15 @@ 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 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
|
||||
command was already retail-faithful. Slice 3's first connected gate exposed
|
||||
three deeper defects, now corrected: the response enum mislabeled ACE's
|
||||
creature bit `0x0100` and dropped monster packets while retaining the busy
|
||||
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
||||
shared main-panel content; and inscription was display-only. Exact flag
|
||||
decoding now reaches the balanced request owner, retail's
|
||||
`gmFloatyExaminationUI` has an independent top-level lifetime, and the
|
||||
imported multiline field sends the exact `SetInscription (0x00BF)`
|
||||
transaction under retail ownership/authorship rules. The corrective connected
|
||||
gate is next.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -97,11 +97,15 @@ 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 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.
|
||||
passed. The pre-existing Assess command has been reconciled against retail.
|
||||
Slice 3's first connected gate found and corrected the real remaining defects:
|
||||
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
||||
appraisal busy reference held, retail's examination UI had been mounted as a
|
||||
shared main-panel page rather than its own floaty window, and inscription was
|
||||
display-only. Exact response flags, the independent
|
||||
`gmFloatyExaminationUI` lifetime, and the retail `SetInscription (0x00BF)`
|
||||
edit transaction are now ported. The corrective connected
|
||||
examination/inscription gate is next.
|
||||
|
||||
Carried:
|
||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
|
||||
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.
|
||||
shared item-cooldown follow-up passed their connected gates. Slice 3's first
|
||||
connected gate exposed an incorrect IdentifyResponse flag table, a non-retail
|
||||
shared-panel mount, and its missing inscription transaction. All three are
|
||||
corrected; the independent floaty examination window awaits its corrective
|
||||
connected gate.
|
||||
**Milestone:** M4 prerequisite/preamble.
|
||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||
interaction state. `GameWindow` remains a composition/callback shell.
|
||||
|
|
@ -19,7 +21,7 @@ quest/emote/character-creation bodies begin:
|
|||
2. The status bar's hand and magnifying-glass controls invoke the same Use and
|
||||
Assess commands as their keyboard paths.
|
||||
3. Assessing a creature, player, NPC, or object opens its retail information in
|
||||
the canonical shared main-panel host.
|
||||
the independent movable/resizable retail floaty examination window.
|
||||
4. World picking can resolve visible equipped children, such as a character's
|
||||
wielded weapon, while selection markers remain anchored to the picked child.
|
||||
5. Vendor use opens the authored vendor surface, publishes its inventory, and
|
||||
|
|
@ -38,7 +40,7 @@ selection state, or optimistic inventory outcomes.
|
|||
|---|---|---|
|
||||
| 1 | Spell-bar overflow arrows | `SpellcastingUiController` + generic retained scrollbar/list |
|
||||
| 2 | Status Use/Assess commands | focused status-bar controller binding to the existing action router |
|
||||
| 3 | Assessment information panel | new retained panel controller mounted by the canonical main-panel host |
|
||||
| 3 | Assessment information panel | retained controller in an independent floaty examination window |
|
||||
| 4 | Equipped-child world picking | pure world-query/picking policy plus presentation anchor |
|
||||
| 5 | Vendor browse lifecycle | vendor session/controller plus authored panel |
|
||||
| 6 | Vendor transactions | server-authoritative buy/sell command and reconciliation owner |
|
||||
|
|
@ -114,19 +116,29 @@ 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.
|
||||
`IdentifyObjectResponse` parsing now uses ACE/retail's exact flag values and
|
||||
positional order, includes the three-word HookProfile, and rejects truncated
|
||||
gated payloads rather than continuing from a corrupt cursor. The first live
|
||||
monster gate found that `0x0100` had been mislabeled WeaponProfile; the parser
|
||||
dropped every creature response before response acceptance, leaving the one
|
||||
busy reference held. A literal ACE `0x0100` creature fixture now protects that
|
||||
packet-to-controller path.
|
||||
|
||||
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:
|
||||
authored scrollbars. `gmFloatyExaminationUI` is an independent top-level
|
||||
window, so Inventory/Skills/Spellbook no longer replace it or inherit its
|
||||
geometry. The imported multiline inscription field now ports
|
||||
`ItemExamineUI`'s public/hook inscribability, inventory ownership and
|
||||
same-scribe permission rules, placeholder/signature presentation, exact
|
||||
failure notices, focus commit behavior, and CP-1252 `SetInscription (0x00BF)`
|
||||
transaction. Basic item, creature, and player reports are live; exact 3D
|
||||
preview/specialized detail regions 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.
|
||||
Release solution build with 17 pre-existing tracked test warnings, 3,496 App
|
||||
tests / 3 skips, and 7,913 complete-solution tests / 5 skips.
|
||||
|
||||
## Slice 1 — spell-bar overflow arrows
|
||||
|
||||
|
|
@ -224,20 +236,27 @@ Named retail references and executable pseudocode are recorded in
|
|||
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.
|
||||
4. `RetailUiRuntime` imports and registers retail's
|
||||
`gmFloatyExaminationUI` as its own top-level window. It deliberately does
|
||||
not enter `RetailPanelUiController`; Inventory, Skills, Spellbook, and
|
||||
effects keep their shared main-panel geometry while Examination coexists.
|
||||
5. Network workers deliver immutable parsed data through the existing
|
||||
session router; retained state changes remain on the update thread.
|
||||
6. `AppraisalUiController` owns inscription presentation and optimistic field
|
||||
state; `WorldSession` owns the exact `0x00BF` GameAction send. Authoritative
|
||||
inventory/appraisal data remains the source on the next response.
|
||||
|
||||
### 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.
|
||||
- Select an item and click the magnifying glass (or press Assess): a separate
|
||||
floaty window should show its name, available properties, descriptions,
|
||||
inscription/signature, retail chrome, and working scrollbars.
|
||||
- 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.
|
||||
refresh; closing the examination window must stop refreshes.
|
||||
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
||||
both windows coexist with independent rectangles.
|
||||
- Assess an owned inscribable weapon. Edit and clear its inscription by
|
||||
clicking elsewhere, then reassess it. An item authored by another player is
|
||||
read-only and reports the exact retail permission line when clicked.
|
||||
|
|
|
|||
|
|
@ -17,13 +17,25 @@ retained examination window.
|
|||
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
|
||||
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
|
||||
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
|
||||
- `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0`
|
||||
- `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230`
|
||||
- `ItemExamineUI::SetInscriptionEditableState @ 0x004AC720`
|
||||
- `ItemExamineUI::SetInscription @ 0x004AE2F0`
|
||||
- `ItemExamineUI::HandleInscriptionLosingFocus @ 0x004AE620`
|
||||
- `gmFloatyExaminationUI::ResizeTo @ 0x004D4470`
|
||||
- `AppraisalProfile::IsHookedItemInscribable @ 0x005B2F90`
|
||||
- `HookAppraisalProfile::IsInscribable @ 0x005B64C0`
|
||||
- `CM_Writing::Event_SetInscription @ 0x006A98B0`
|
||||
- `PublicWeenieDesc::BF_INSCRIBABLE` in `acclient.h:6434`
|
||||
- Retail LayoutDesc `0x2100006B`, root `0x100005F2`
|
||||
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
|
||||
- ACE:
|
||||
- `IdentifyResponseFlags`
|
||||
- `GameEventIdentifyObjectResponse`
|
||||
- `AppraiseInfo.Write`
|
||||
- `HookProfile.Write`
|
||||
- `GameActionSetInscription`
|
||||
- `Player.HandleActionSetInscription`
|
||||
- ACViewer's `HookAppraisalProfile` representation, used as a second
|
||||
interpretation check for the three-field hook profile.
|
||||
|
||||
|
|
@ -63,6 +75,30 @@ for the current pending GUID releases that one reference.
|
|||
|
||||
## IdentifyObjectResponse packet order
|
||||
|
||||
The flag values are part of the wire contract and are not the field-order
|
||||
indices:
|
||||
|
||||
```text
|
||||
IntStatsTable 0x0001
|
||||
BoolStatsTable 0x0002
|
||||
FloatStatsTable 0x0004
|
||||
StringStatsTable 0x0008
|
||||
SpellBook 0x0010
|
||||
WeaponProfile 0x0020
|
||||
HookProfile 0x0040
|
||||
ArmorProfile 0x0080
|
||||
CreatureProfile 0x0100
|
||||
ArmorEnchantmentBitfield 0x0200
|
||||
ResistEnchantmentBitfield 0x0400
|
||||
WeaponEnchantmentBitfield 0x0800
|
||||
DidStatsTable 0x1000
|
||||
Int64StatsTable 0x2000
|
||||
ArmorLevels 0x4000
|
||||
```
|
||||
|
||||
ACE writes the gated bodies in the order below, independent of the numerical
|
||||
position of each flag.
|
||||
|
||||
```text
|
||||
read guid
|
||||
read flags
|
||||
|
|
@ -178,16 +214,126 @@ same object preserves the user's position.
|
|||
|
||||
The LayoutDesc root already contains retail's complete floaty window chrome,
|
||||
close control, item/creature/character/spell subviews, and scrollbars.
|
||||
The root's concrete retail type is `gmFloatyExaminationUI`, not a child of
|
||||
`gmPanelUI`. It is mounted and persisted as its own top-level retained window.
|
||||
Showing it must not hide, replace, resize, or reposition the shared
|
||||
Inventory/Skills/Spellbook panel.
|
||||
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
|
||||
and persists width/height; it adds no special resize algorithm.
|
||||
|
||||
## Slice boundary
|
||||
## Inscription presentation and eligibility
|
||||
|
||||
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.
|
||||
```text
|
||||
SetInscription(profile):
|
||||
reset inscription field to its unfocused state
|
||||
clear displayed text, signature, old inscription, scribe name/account
|
||||
|
||||
inscribable = object.PublicWeenieDesc has BF_INSCRIBABLE
|
||||
if object is a hook:
|
||||
inscribable = profile.HookProfile exists
|
||||
and (HookProfile.Flags & 1) != 0
|
||||
|
||||
if not inscribable:
|
||||
hide inscription field and signature
|
||||
return
|
||||
|
||||
scribe = profile string property 8, or empty
|
||||
oldInscription = profile string property 7, or empty
|
||||
|
||||
if scribe is empty:
|
||||
field = "<Inscribe here>"
|
||||
signature = ""
|
||||
else:
|
||||
field = oldInscription
|
||||
signature = "--" + scribe
|
||||
if local player is a PSR and scribe-account property 0x17 exists:
|
||||
signature += " <" + scribeAccount + ">"
|
||||
|
||||
show field and signature
|
||||
SetInscriptionEditableState()
|
||||
|
||||
SetInscriptionEditableState:
|
||||
editable = object exists
|
||||
and object has BF_INSCRIBABLE
|
||||
and object is owned by the local player
|
||||
and (scribe is empty
|
||||
or case-insensitive scribe == local player name)
|
||||
PSR may override the ownership/scribe restriction
|
||||
field.Editable = editable
|
||||
field.Selectable = editable
|
||||
inscription background receives mouse presses only when not editable
|
||||
```
|
||||
|
||||
The ordinary-player port does not fabricate PSR status. It applies the exact
|
||||
ownership/name rule; an eventual PSR player-description capability can add the
|
||||
retail override without changing this transaction.
|
||||
|
||||
For hooks, the appraisal HookProfile is the source of truth for presentation:
|
||||
`AppraisalProfile::IsHookedItemInscribable` returns false when it is absent and
|
||||
otherwise returns bit 0. The live public description remains the source used by
|
||||
`SetInscriptionEditableState`, matching the two retail functions rather than
|
||||
merging them into one guessed rule.
|
||||
|
||||
Noneditable mouse presses report retail's exact reason:
|
||||
|
||||
```text
|
||||
if scribe is nonempty and differs from local player:
|
||||
"Only <scribe> can change the inscription"
|
||||
else if object is absent or not owned:
|
||||
"Item must be in your inventory to inscribe."
|
||||
else if BF_INSCRIBABLE is absent:
|
||||
"This item is not inscribable."
|
||||
```
|
||||
|
||||
## Inscription edit transaction
|
||||
|
||||
```text
|
||||
on inscription focus gained:
|
||||
if scribe is empty:
|
||||
switch to the normal editing state
|
||||
clear "<Inscribe here>"
|
||||
signature = "--" + local player name
|
||||
|
||||
on inscription focus lost:
|
||||
if current object id == 0:
|
||||
return
|
||||
|
||||
text = current field text
|
||||
skipSend = text is empty and original scribe is empty
|
||||
if not skipSend and text != oldInscription:
|
||||
send CM_Writing.Event_SetInscription(object id, text)
|
||||
|
||||
if text is empty:
|
||||
restore unfocused state
|
||||
field = "<Inscribe here>"
|
||||
signature = ""
|
||||
scribe name/account = ""
|
||||
else:
|
||||
scribe name = local player name
|
||||
signature = "--" + local player name
|
||||
|
||||
oldInscription = text
|
||||
```
|
||||
|
||||
`CM_Writing::Event_SetInscription` sends a normal UI-counter GameAction:
|
||||
|
||||
```text
|
||||
u32 0xF7B1 GameAction envelope
|
||||
u32 uiCounter
|
||||
u32 0x00BF SetInscription
|
||||
u32 objectGuid
|
||||
String16L text CP-1252, padded to a four-byte boundary
|
||||
```
|
||||
|
||||
There is no successful server reply in retail captures. The client therefore
|
||||
updates its field/signature optimistically after sending; ACE deliberately
|
||||
implements the same behavior.
|
||||
|
||||
## Remaining slice boundary
|
||||
|
||||
The request lifetime, exact packet flags, independent floaty window,
|
||||
inscription permission/edit/write transaction, subview choice, authored outer
|
||||
layout, basic report content, scrollbar ownership, and combat refresh are in
|
||||
scope. Retail's exact 3D object/creature preview, exhaustive specialized
|
||||
`ItemExamineUI` detail branches, selection-follow preference, and PSR-only
|
||||
scribe-account/override behavior remain explicit AP-110 work.
|
||||
|
|
|
|||
|
|
@ -622,6 +622,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
type,
|
||||
context,
|
||||
accepted)),
|
||||
Appraisal: new AppraisalRuntimeBindings(
|
||||
characterSheet.CharacterName,
|
||||
(item, inscription) =>
|
||||
late.Session.CurrentSession?.SendSetInscription(
|
||||
item,
|
||||
inscription),
|
||||
text => d.Chat.OnSystemMessage(text, 0x1Au)),
|
||||
StackSplitQuantity: d.StackSplitQuantity,
|
||||
Plugins: d.UiRegistry,
|
||||
Persistence: persistence,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
public const uint ItemPanelId = 0x1000012Eu;
|
||||
public const uint ItemTextId = 0x1000013Cu;
|
||||
public const uint ItemScrollbarId = 0x1000013Du;
|
||||
public const uint InscriptionBackgroundId = 0x10000137u;
|
||||
public const uint InscriptionTextId = 0x1000013Eu;
|
||||
public const uint SignatureTextId = 0x1000013Fu;
|
||||
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
||||
|
|
@ -38,6 +39,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly ItemInteractionController _interaction;
|
||||
private readonly CombatState _combat;
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly Func<string> _playerName;
|
||||
private readonly Action<uint, string> _sendSetInscription;
|
||||
private readonly Action<string> _systemMessage;
|
||||
private readonly Action _show;
|
||||
private readonly UiElement _itemPanel;
|
||||
private readonly UiElement _creaturePanel;
|
||||
|
|
@ -47,6 +51,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private readonly UiText? _inscriptionText;
|
||||
private readonly UiField? _inscriptionField;
|
||||
private readonly UiText? _signature;
|
||||
private readonly UiDatElement? _inscriptionBackground;
|
||||
private readonly UiButton? _close;
|
||||
private AppraisalView _activeView;
|
||||
private uint _itemObjectId;
|
||||
|
|
@ -56,6 +61,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
private string _itemReport = string.Empty;
|
||||
private string _inscriptionValue = string.Empty;
|
||||
private string _signatureValue = string.Empty;
|
||||
private string _scribeName = string.Empty;
|
||||
private string _oldInscription = string.Empty;
|
||||
private bool _presentationInscribable;
|
||||
private double _refreshElapsed;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -65,6 +73,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Func<string> playerName,
|
||||
Action<uint, string> sendSetInscription,
|
||||
Action<string> systemMessage,
|
||||
Action show,
|
||||
Action close,
|
||||
UiElement itemPanel,
|
||||
|
|
@ -77,6 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_interaction = interaction;
|
||||
_combat = combat;
|
||||
_spellbook = spellbook;
|
||||
_playerName = playerName;
|
||||
_sendSetInscription = sendSetInscription;
|
||||
_systemMessage = systemMessage;
|
||||
_show = show;
|
||||
_itemPanel = itemPanel;
|
||||
_creaturePanel = creaturePanel;
|
||||
|
|
@ -86,6 +100,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||
_inscriptionBackground =
|
||||
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
||||
_close = layout.FindElement(CloseId) as UiButton;
|
||||
|
||||
_title.LinesProvider = () =>
|
||||
|
|
@ -99,17 +115,26 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
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;
|
||||
_inscriptionField.ClearOnSubmit = false;
|
||||
_inscriptionField.RecordHistory = false;
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.OnFocusGained = HandleInscriptionGainingFocus;
|
||||
_inscriptionField.OnFocusLost = HandleInscriptionLosingFocus;
|
||||
_inscriptionField.OnReadOnlyClick = ReportInscriptionUnavailable;
|
||||
if (layout.FindElement(InscriptionScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _inscriptionField.Scroll;
|
||||
}
|
||||
if (_signature is not null)
|
||||
_signature.LinesProvider = () =>
|
||||
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
|
||||
if (_close is not null)
|
||||
_close.OnClick = close;
|
||||
if (_inscriptionBackground is not null)
|
||||
{
|
||||
_inscriptionBackground.OnClick = ReportInscriptionUnavailable;
|
||||
_inscriptionBackground.ClickThrough = false;
|
||||
}
|
||||
|
||||
SetActiveView(AppraisalView.Item);
|
||||
}
|
||||
|
|
@ -123,6 +148,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
Spellbook spellbook,
|
||||
Func<string> playerName,
|
||||
Action<uint, string> sendSetInscription,
|
||||
Action<string> systemMessage,
|
||||
Action show,
|
||||
Action close)
|
||||
{
|
||||
|
|
@ -131,6 +159,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
ArgumentNullException.ThrowIfNull(interaction);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(playerName);
|
||||
ArgumentNullException.ThrowIfNull(sendSetInscription);
|
||||
ArgumentNullException.ThrowIfNull(systemMessage);
|
||||
ArgumentNullException.ThrowIfNull(show);
|
||||
ArgumentNullException.ThrowIfNull(close);
|
||||
|
||||
|
|
@ -146,6 +177,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
interaction,
|
||||
combat,
|
||||
spellbook,
|
||||
playerName,
|
||||
sendSetInscription,
|
||||
systemMessage,
|
||||
show,
|
||||
close,
|
||||
itemPanel,
|
||||
|
|
@ -228,7 +262,19 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_itemReport = string.Empty;
|
||||
_inscriptionValue = string.Empty;
|
||||
_inscriptionField?.SetText(string.Empty);
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.Visible = false;
|
||||
_inscriptionField.Scroll.SetScrollY(0);
|
||||
}
|
||||
_signatureValue = string.Empty;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = false;
|
||||
_scribeName = string.Empty;
|
||||
_oldInscription = string.Empty;
|
||||
_presentationInscribable = false;
|
||||
_itemObjectId = 0;
|
||||
_creatureObjectId = 0;
|
||||
_characterObjectId = 0;
|
||||
|
|
@ -246,19 +292,158 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
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}";
|
||||
SetInscription(obj, appraisal);
|
||||
if (newlySelected)
|
||||
{
|
||||
_itemText.Scroll.SetScrollY(0);
|
||||
_inscriptionText?.Scroll.SetScrollY(0);
|
||||
_inscriptionField?.Scroll.SetScrollY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>ItemExamineUI::SetInscription @ 0x004AE2F0</c> and
|
||||
/// <c>SetInscriptionEditableState @ 0x004AC720</c>.
|
||||
/// </summary>
|
||||
private void SetInscription(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal)
|
||||
{
|
||||
_oldInscription = string.Empty;
|
||||
_scribeName = string.Empty;
|
||||
_inscriptionValue = string.Empty;
|
||||
_signatureValue = string.Empty;
|
||||
|
||||
var publicFlags =
|
||||
(PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
|
||||
bool publicInscribable =
|
||||
(publicFlags & PublicWeenieFlags.Inscribable) != 0;
|
||||
_presentationInscribable = appraisal.HookProfile is { } hook
|
||||
? (hook.Flags & 0x1u) != 0
|
||||
: publicInscribable;
|
||||
|
||||
if (!_presentationInscribable)
|
||||
{
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(string.Empty);
|
||||
_inscriptionField.Editable = false;
|
||||
_inscriptionField.Selectable = false;
|
||||
_inscriptionField.Visible = false;
|
||||
}
|
||||
if (_inscriptionText is not null)
|
||||
_inscriptionText.Visible = false;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = false;
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.ClickThrough = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_scribeName = GetString(appraisal.Properties, 8u);
|
||||
_oldInscription = GetString(appraisal.Properties, 7u);
|
||||
if (string.IsNullOrEmpty(_scribeName))
|
||||
{
|
||||
_inscriptionValue = "<Inscribe here>";
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_oldInscription))
|
||||
{
|
||||
_inscriptionValue = _oldInscription;
|
||||
_signatureValue = $"--{_scribeName}";
|
||||
}
|
||||
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.SetText(_inscriptionValue);
|
||||
_inscriptionField.Visible = true;
|
||||
}
|
||||
if (_inscriptionText is not null)
|
||||
_inscriptionText.Visible = true;
|
||||
if (_signature is not null)
|
||||
_signature.Visible = true;
|
||||
|
||||
bool editable = publicInscribable
|
||||
&& _interaction.IsOwnedByPlayer(obj.ObjectId)
|
||||
&& (string.IsNullOrEmpty(_scribeName)
|
||||
|| string.Equals(
|
||||
_scribeName,
|
||||
_playerName(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.Editable = editable;
|
||||
_inscriptionField.Selectable = editable;
|
||||
}
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.ClickThrough = editable;
|
||||
}
|
||||
|
||||
private void HandleInscriptionGainingFocus()
|
||||
{
|
||||
if (_inscriptionField is not { Editable: true } field)
|
||||
return;
|
||||
if (string.IsNullOrEmpty(_scribeName))
|
||||
{
|
||||
field.SetText(string.Empty);
|
||||
_inscriptionValue = string.Empty;
|
||||
}
|
||||
_signatureValue = $"--{_playerName()}";
|
||||
}
|
||||
|
||||
private void HandleInscriptionLosingFocus(string text)
|
||||
{
|
||||
if (_itemObjectId == 0 || !_presentationInscribable)
|
||||
return;
|
||||
|
||||
bool skipEmptyUnscribed = string.IsNullOrEmpty(text)
|
||||
&& string.IsNullOrEmpty(_scribeName);
|
||||
if (!skipEmptyUnscribed
|
||||
&& !string.Equals(
|
||||
text,
|
||||
_oldInscription,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
_sendSetInscription(_itemObjectId, text);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
_inscriptionValue = "<Inscribe here>";
|
||||
_signatureValue = string.Empty;
|
||||
_scribeName = string.Empty;
|
||||
_inscriptionField?.SetText(_inscriptionValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
_inscriptionValue = text;
|
||||
_scribeName = _playerName();
|
||||
_signatureValue = $"--{_scribeName}";
|
||||
}
|
||||
_oldInscription = text;
|
||||
}
|
||||
|
||||
private void ReportInscriptionUnavailable()
|
||||
{
|
||||
if (_inscriptionField?.Editable == true)
|
||||
return;
|
||||
if (!string.IsNullOrEmpty(_scribeName)
|
||||
&& !string.Equals(
|
||||
_scribeName,
|
||||
_playerName(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_systemMessage($"Only {_scribeName} can change the inscription");
|
||||
return;
|
||||
}
|
||||
if (_itemObjectId == 0
|
||||
|| !_interaction.IsOwnedByPlayer(_itemObjectId))
|
||||
{
|
||||
_systemMessage("Item must be in your inventory to inscribe.");
|
||||
return;
|
||||
}
|
||||
_systemMessage("This item is not inscribable.");
|
||||
}
|
||||
|
||||
private void ApplyCreature(
|
||||
ClientObject obj,
|
||||
AppraiseInfoParser.Parsed appraisal,
|
||||
|
|
@ -411,10 +596,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
_disposed = true;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
if (_inscriptionField is not null)
|
||||
{
|
||||
_inscriptionField.OnFocusGained = null;
|
||||
_inscriptionField.OnFocusLost = null;
|
||||
_inscriptionField.OnReadOnlyClick = null;
|
||||
}
|
||||
if (_inscriptionBackground is not null)
|
||||
_inscriptionBackground.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)))
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll))
|
||||
|| (_inscriptionField is not null
|
||||
&& ReferenceEquals(scrollbar.Model, _inscriptionField.Scroll)))
|
||||
scrollbar.Model = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,6 @@ 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 =
|
||||
{
|
||||
|
|
@ -34,7 +28,6 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
(Examination, WindowNames.Examination),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ public sealed record RetailUiCursorBindings(
|
|||
public sealed record ConfirmationRuntimeBindings(
|
||||
Action<uint, uint, bool> SendResponse);
|
||||
|
||||
public sealed record AppraisalRuntimeBindings(
|
||||
Func<string> PlayerName,
|
||||
Action<uint, string> SendSetInscription,
|
||||
Action<string> DisplaySystemMessage);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
RetailUiAssets Assets,
|
||||
|
|
@ -177,6 +182,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ExternalContainerRuntimeBindings ExternalContainer,
|
||||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
AppraisalRuntimeBindings Appraisal,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
|
|
@ -431,7 +437,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
ResetSessionDialogs();
|
||||
AppraisalController?.ResetSession();
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
|
||||
Host.HideWindow(WindowNames.Examination);
|
||||
}
|
||||
|
||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||
|
|
@ -971,9 +977,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Inventory.ItemInteraction,
|
||||
_bindings.Combat.State,
|
||||
_bindings.Magic.Spellbook,
|
||||
show: () => _panelUi.SetPanelVisibility(
|
||||
RetailPanelCatalog.Examination,
|
||||
visible: true),
|
||||
_bindings.Appraisal.PlayerName,
|
||||
_bindings.Appraisal.SendSetInscription,
|
||||
_bindings.Appraisal.DisplaySystemMessage,
|
||||
show: () => Host.ShowWindow(WindowNames.Examination),
|
||||
close: () => CloseWindow(WindowNames.Examination));
|
||||
if (controller is null)
|
||||
{
|
||||
|
|
@ -984,7 +991,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
AppraisalController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
_ = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
|
|
@ -1006,10 +1013,6 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.Examination,
|
||||
WindowNames.Examination,
|
||||
handle);
|
||||
Console.WriteLine(
|
||||
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using System.Numerics;
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// Generic editable field widget. Port of retail <c>UIElement_Field</c>
|
||||
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
|
||||
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
|
||||
/// as stubs for future item-window use.
|
||||
|
|
@ -22,6 +22,8 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiField : UiElement
|
||||
{
|
||||
private readonly record struct WrappedLine(int Start, int Length, string Text);
|
||||
|
||||
public uint ElementId { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
|
|
@ -39,6 +41,31 @@ public sealed class UiField : UiElement
|
|||
public bool RecordHistory { get; set; } = true;
|
||||
public Func<char, bool>? CharacterFilter { get; set; }
|
||||
public bool SelectAllOnFocus { get; set; }
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
public Action? OnReadOnlyClick { get; set; }
|
||||
|
||||
private bool _editable = true;
|
||||
|
||||
/// <summary>
|
||||
/// Retail property 0x16. Read-only fields remain mouse-visible so their
|
||||
/// authored background can report why editing is unavailable, but they do
|
||||
/// not take keyboard focus or accept mutations.
|
||||
/// </summary>
|
||||
public bool Editable
|
||||
{
|
||||
get => _editable;
|
||||
set
|
||||
{
|
||||
if (_editable == value) return;
|
||||
_editable = value;
|
||||
AcceptsFocus = value;
|
||||
IsEditControl = value;
|
||||
if (!value && _focused)
|
||||
FindRoot()?.SetKeyboardFocus(null);
|
||||
if (!value)
|
||||
_repeatKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
|
|
@ -72,6 +99,9 @@ public sealed class UiField : UiElement
|
|||
private bool _selecting; // mouse drag in progress
|
||||
private bool _preserveFocusSelectionOnMouseDown;
|
||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
||||
private IReadOnlyList<WrappedLine> _wrappedLines = Array.Empty<WrappedLine>();
|
||||
private float _wrappedLineHeight = 14f;
|
||||
private bool _suppressNextNewlineChar;
|
||||
|
||||
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||
private Silk.NET.Input.Key? _repeatKey;
|
||||
|
|
@ -94,7 +124,12 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void InsertChar(char c)
|
||||
{
|
||||
if (c < 0x20 || c == 0x7F || (CharacterFilter is not null && !CharacterFilter(c))) return;
|
||||
if (!Editable) return;
|
||||
if (!OneLine && (c == '\r' || c == '\n'))
|
||||
c = '\n';
|
||||
else if (c < 0x20 || c == 0x7F)
|
||||
return;
|
||||
if (CharacterFilter is not null && !CharacterFilter(c)) return;
|
||||
DeleteSelection();
|
||||
if (_text.Length >= MaxCharacters) return;
|
||||
_text = _text.Insert(_caret, c.ToString());
|
||||
|
|
@ -104,6 +139,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void Backspace()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret == 0) return;
|
||||
_text = _text.Remove(_caret - 1, 1);
|
||||
|
|
@ -112,6 +148,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void DeleteForward()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (DeleteSelection()) return;
|
||||
if (_caret >= _text.Length) return;
|
||||
_text = _text.Remove(_caret, 1);
|
||||
|
|
@ -184,6 +221,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
private void CutSelection()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (!HasSelection) return;
|
||||
CopySelection();
|
||||
DeleteSelection();
|
||||
|
|
@ -192,15 +230,33 @@ public sealed class UiField : UiElement
|
|||
|
||||
private void Paste()
|
||||
{
|
||||
if (!Editable) return;
|
||||
if (Keyboard is null) return;
|
||||
string clip = Keyboard.ClipboardText ?? "";
|
||||
if (clip.Length == 0) return;
|
||||
|
||||
// Single-line field: strip control chars (newlines/tabs) from pasted text.
|
||||
// Retail one-line fields strip controls. Multi-line fields preserve CR/LF
|
||||
// as one normalized newline and still reject other controls.
|
||||
var sb = new System.Text.StringBuilder(clip.Length);
|
||||
bool previousCr = false;
|
||||
foreach (char ch in clip)
|
||||
{
|
||||
if (!OneLine && (ch == '\r' || ch == '\n'))
|
||||
{
|
||||
if (ch == '\n' && previousCr)
|
||||
{
|
||||
previousCr = false;
|
||||
continue;
|
||||
}
|
||||
sb.Append('\n');
|
||||
previousCr = ch == '\r';
|
||||
continue;
|
||||
}
|
||||
previousCr = false;
|
||||
if (ch >= 0x20 && ch != 0x7F
|
||||
&& (CharacterFilter is null || CharacterFilter(ch))) sb.Append(ch);
|
||||
&& (CharacterFilter is null || CharacterFilter(ch)))
|
||||
sb.Append(ch);
|
||||
}
|
||||
if (sb.Length == 0) return;
|
||||
|
||||
DeleteSelection();
|
||||
|
|
@ -216,6 +272,7 @@ public sealed class UiField : UiElement
|
|||
|
||||
public void Submit()
|
||||
{
|
||||
if (!Editable) return;
|
||||
var t = _text;
|
||||
if (t.Trim().Length == 0)
|
||||
{
|
||||
|
|
@ -314,6 +371,12 @@ public sealed class UiField : UiElement
|
|||
}
|
||||
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||
|
||||
if (!OneLine)
|
||||
{
|
||||
DrawMultiLine(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float ty = (Height - lh) * 0.5f;
|
||||
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
||||
|
|
@ -370,11 +433,198 @@ public sealed class UiField : UiElement
|
|||
return 0f;
|
||||
}
|
||||
|
||||
private void DrawMultiLine(UiRenderContext ctx)
|
||||
{
|
||||
float lineHeight = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
float visibleWidth = MathF.Max(1f, Width - (2f * Padding));
|
||||
float visibleHeight = MathF.Max(1f, Height - (2f * Padding));
|
||||
IReadOnlyList<WrappedLine> lines = BuildWrappedLines(visibleWidth);
|
||||
_wrappedLines = lines;
|
||||
_wrappedLineHeight = lineHeight;
|
||||
|
||||
Scroll.LineHeight = Math.Max(1, (int)MathF.Round(lineHeight));
|
||||
Scroll.SetExtents(
|
||||
Math.Max(1, (int)MathF.Ceiling(lines.Count * lineHeight)),
|
||||
Math.Max(1, (int)MathF.Floor(visibleHeight)),
|
||||
preserveEnd: false);
|
||||
|
||||
int caretLine = FindCaretLine(lines, _caret);
|
||||
if (_focused)
|
||||
{
|
||||
float caretTop = caretLine * lineHeight;
|
||||
float caretBottom = caretTop + lineHeight;
|
||||
if (caretTop < Scroll.ScrollY)
|
||||
Scroll.SetScrollY((int)MathF.Floor(caretTop));
|
||||
else if (caretBottom > Scroll.ScrollY + visibleHeight)
|
||||
Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight));
|
||||
}
|
||||
|
||||
ctx.PushClip(0f, 0f, Width, Height);
|
||||
try
|
||||
{
|
||||
var (selectionLow, selectionHigh) = SelSpan();
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
WrappedLine line = lines[i];
|
||||
float y = Padding + (i * lineHeight) - Scroll.ScrollY;
|
||||
if (y + lineHeight <= Padding || y >= Height - Padding)
|
||||
continue;
|
||||
|
||||
int lineEnd = line.Start + line.Length;
|
||||
int highlightLow = Math.Max(selectionLow, line.Start);
|
||||
int highlightHigh = Math.Min(selectionHigh, lineEnd);
|
||||
if (HasSelection && highlightHigh > highlightLow)
|
||||
{
|
||||
float x0 = Padding + MeasureRange(
|
||||
line.Start,
|
||||
highlightLow - line.Start);
|
||||
float x1 = Padding + MeasureRange(
|
||||
line.Start,
|
||||
highlightHigh - line.Start);
|
||||
ctx.DrawFill(
|
||||
x0,
|
||||
y,
|
||||
MathF.Max(0f, x1 - x0),
|
||||
lineHeight,
|
||||
SelectionColor);
|
||||
}
|
||||
|
||||
if (DatFont is { } dat)
|
||||
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor);
|
||||
else if (Font is { } bitmap)
|
||||
ctx.DrawString(line.Text, Padding, y, TextColor, bitmap);
|
||||
}
|
||||
|
||||
if (_focused && lines.Count > 0)
|
||||
{
|
||||
WrappedLine line = lines[caretLine];
|
||||
int lineColumn = Math.Clamp(
|
||||
_caret - line.Start,
|
||||
0,
|
||||
line.Length);
|
||||
float x = Padding + MeasureRange(line.Start, lineColumn);
|
||||
float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY;
|
||||
ctx.DrawFill(x, y, 1f, lineHeight, TextColor);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.PopClip();
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<WrappedLine> BuildWrappedLines(float maximumWidth)
|
||||
{
|
||||
var lines = new List<WrappedLine>();
|
||||
if (_text.Length == 0)
|
||||
{
|
||||
lines.Add(new WrappedLine(0, 0, string.Empty));
|
||||
return lines;
|
||||
}
|
||||
|
||||
int start = 0;
|
||||
while (start < _text.Length)
|
||||
{
|
||||
if (_text[start] == '\n')
|
||||
{
|
||||
lines.Add(new WrappedLine(start, 0, string.Empty));
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int paragraphEnd = _text.IndexOf('\n', start);
|
||||
if (paragraphEnd < 0)
|
||||
paragraphEnd = _text.Length;
|
||||
int end = start;
|
||||
int lastWhitespaceEnd = -1;
|
||||
while (end < paragraphEnd)
|
||||
{
|
||||
int candidateEnd = end + 1;
|
||||
if (MeasureRange(start, candidateEnd - start) > maximumWidth
|
||||
&& end > start)
|
||||
break;
|
||||
end = candidateEnd;
|
||||
if (char.IsWhiteSpace(_text[end - 1]))
|
||||
lastWhitespaceEnd = end;
|
||||
}
|
||||
|
||||
if (end < paragraphEnd && lastWhitespaceEnd > start)
|
||||
end = lastWhitespaceEnd;
|
||||
if (end == start)
|
||||
end++;
|
||||
|
||||
int length = end - start;
|
||||
lines.Add(new WrappedLine(
|
||||
start,
|
||||
length,
|
||||
_text.Substring(start, length)));
|
||||
start = end;
|
||||
if (start == paragraphEnd && start < _text.Length)
|
||||
start++;
|
||||
}
|
||||
|
||||
if (_text.EndsWith('\n'))
|
||||
lines.Add(new WrappedLine(_text.Length, 0, string.Empty));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private int FindCaretLine(IReadOnlyList<WrappedLine> lines, int caret)
|
||||
{
|
||||
for (int i = 0; i < lines.Count; i++)
|
||||
{
|
||||
WrappedLine line = lines[i];
|
||||
int end = line.Start + line.Length;
|
||||
if (caret < end || caret == end && i == lines.Count - 1)
|
||||
return i;
|
||||
if (caret == end && i + 1 < lines.Count
|
||||
&& lines[i + 1].Start > caret)
|
||||
return i;
|
||||
}
|
||||
return Math.Max(0, lines.Count - 1);
|
||||
}
|
||||
|
||||
private float MeasureRange(int start, int length)
|
||||
{
|
||||
if (length <= 0) return 0f;
|
||||
string value = _text.Substring(start, length);
|
||||
return DatFont?.MeasureWidth(value)
|
||||
?? Font?.MeasureWidth(value)
|
||||
?? value.Length * 8f;
|
||||
}
|
||||
|
||||
private int HitChar(float localX, float localY)
|
||||
{
|
||||
if (OneLine)
|
||||
return HitCharX(localX);
|
||||
if (_wrappedLines.Count == 0)
|
||||
return _text.Length;
|
||||
|
||||
int lineIndex = Math.Clamp(
|
||||
(int)MathF.Floor(
|
||||
(localY - Padding + Scroll.ScrollY)
|
||||
/ MathF.Max(1f, _wrappedLineHeight)),
|
||||
0,
|
||||
_wrappedLines.Count - 1);
|
||||
WrappedLine line = _wrappedLines[lineIndex];
|
||||
float target = MathF.Max(0f, localX - Padding);
|
||||
int best = 0;
|
||||
float bestDistance = float.MaxValue;
|
||||
for (int col = 0; col <= line.Length; col++)
|
||||
{
|
||||
float distance = MathF.Abs(
|
||||
MeasureRange(line.Start, col) - target);
|
||||
if (distance >= bestDistance) continue;
|
||||
bestDistance = distance;
|
||||
best = col;
|
||||
}
|
||||
return line.Start + best;
|
||||
}
|
||||
|
||||
// ── Auto-repeat ──────────────────────────────────────────────────────
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
if (_repeatKey is not { } k) return;
|
||||
if (!Editable || _repeatKey is not { } k) return;
|
||||
_repeatTimer -= deltaSeconds;
|
||||
if (_repeatTimer > 0) return;
|
||||
_repeatTimer = RepeatRate;
|
||||
|
|
@ -425,8 +675,18 @@ public sealed class UiField : UiElement
|
|||
return true;
|
||||
|
||||
case UiEventType.Char:
|
||||
InsertChar((char)e.Data0);
|
||||
{
|
||||
char value = (char)e.Data0;
|
||||
if (_suppressNextNewlineChar
|
||||
&& (value == '\r' || value == '\n'))
|
||||
{
|
||||
_suppressNextNewlineChar = false;
|
||||
return true;
|
||||
}
|
||||
_suppressNextNewlineChar = false;
|
||||
InsertChar(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.MouseDown:
|
||||
if (_preserveFocusSelectionOnMouseDown)
|
||||
|
|
@ -435,12 +695,13 @@ public sealed class UiField : UiElement
|
|||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
_caret = HitCharX(e.Data1);
|
||||
_caret = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = Selectable ? _caret : null;
|
||||
_selecting = Selectable;
|
||||
return true;
|
||||
case UiEventType.MouseMove:
|
||||
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
|
||||
if (Selectable && _selecting)
|
||||
_caret = HitChar(e.Data1, e.Data2);
|
||||
return true;
|
||||
case UiEventType.MouseUp:
|
||||
_selecting = false;
|
||||
|
|
@ -452,6 +713,8 @@ public sealed class UiField : UiElement
|
|||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
if (!Editable)
|
||||
return true;
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
if (CtrlHeld())
|
||||
{
|
||||
|
|
@ -470,6 +733,12 @@ public sealed class UiField : UiElement
|
|||
{
|
||||
case Silk.NET.Input.Key.Enter:
|
||||
case Silk.NET.Input.Key.KeypadEnter:
|
||||
if (!OneLine)
|
||||
{
|
||||
InsertChar('\n');
|
||||
_suppressNextNewlineChar = true;
|
||||
return true;
|
||||
}
|
||||
Submit();
|
||||
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
||||
return true;
|
||||
|
|
@ -484,6 +753,20 @@ public sealed class UiField : UiElement
|
|||
}
|
||||
return false;
|
||||
}
|
||||
case UiEventType.Scroll:
|
||||
if (!OneLine)
|
||||
{
|
||||
Scroll.ScrollByLines(-Math.Sign(e.Data0));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case UiEventType.Click:
|
||||
if (!Editable)
|
||||
{
|
||||
OnReadOnlyClick?.Invoke();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,19 +60,19 @@ public static class AppraiseInfoParser
|
|||
{
|
||||
None = 0x0000_0000,
|
||||
IntStatsTable = 0x0000_0001,
|
||||
Int64StatsTable = 0x0000_0002,
|
||||
BoolStatsTable = 0x0000_0004,
|
||||
FloatStatsTable = 0x0000_0008,
|
||||
StringStatsTable = 0x0000_0010,
|
||||
DidStatsTable = 0x0000_0020,
|
||||
SpellBook = 0x0000_0040,
|
||||
BoolStatsTable = 0x0000_0002,
|
||||
FloatStatsTable = 0x0000_0004,
|
||||
StringStatsTable = 0x0000_0008,
|
||||
SpellBook = 0x0000_0010,
|
||||
WeaponProfile = 0x0000_0020,
|
||||
HookProfile = 0x0000_0040,
|
||||
ArmorProfile = 0x0000_0080,
|
||||
WeaponProfile = 0x0000_0100,
|
||||
HookProfile = 0x0000_0200,
|
||||
ArmorEnchantmentBitfield = 0x0000_0400,
|
||||
CreatureProfile = 0x0000_0100,
|
||||
ArmorEnchantmentBitfield = 0x0000_0200,
|
||||
ResistEnchantmentBitfield= 0x0000_0400,
|
||||
WeaponEnchantmentBitfield= 0x0000_0800,
|
||||
ResistEnchantmentBitfield= 0x0000_1000,
|
||||
CreatureProfile = 0x0000_2000,
|
||||
DidStatsTable = 0x0000_1000,
|
||||
Int64StatsTable = 0x0000_2000,
|
||||
ArmorLevels = 0x0000_4000,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public static class InventoryActions
|
|||
public const uint GetAndWieldItemOpcode = 0x001Au;
|
||||
public const uint DropItemOpcode = 0x001Bu;
|
||||
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
||||
public const uint SetInscriptionOpcode = 0x00BFu;
|
||||
|
||||
/// <summary>
|
||||
/// Merge stack A into stack B of the same item type. Server validates
|
||||
|
|
@ -172,4 +173,36 @@ public static class InventoryActions
|
|||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set or clear an item's inscription. Retail
|
||||
/// <c>CM_Writing::Event_SetInscription @ 0x006A98B0</c> writes the object
|
||||
/// GUID followed by one CP-1252 String16L and receives no success response.
|
||||
/// </summary>
|
||||
public static byte[] BuildSetInscription(
|
||||
uint seq,
|
||||
uint itemGuid,
|
||||
string inscription)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inscription);
|
||||
byte[] text = Encodings.Windows1252.GetBytes(inscription);
|
||||
if (text.Length > ushort.MaxValue)
|
||||
throw new ArgumentException(
|
||||
"Inscription is too long for String16L.",
|
||||
nameof(inscription));
|
||||
|
||||
int stringRecordLength = (2 + text.Length + 3) & ~3;
|
||||
byte[] body = new byte[16 + stringRecordLength];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
body.AsSpan(8),
|
||||
SetInscriptionOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||
body.AsSpan(16),
|
||||
(ushort)text.Length);
|
||||
text.CopyTo(body, 18);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1788,6 +1788,20 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail SetInscription (0x00BF). The retail client updates the
|
||||
/// examination field optimistically because the server sends no success
|
||||
/// response.
|
||||
/// </summary>
|
||||
public void SendSetInscription(uint itemGuid, string inscription)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(InventoryActions.BuildSetInscription(
|
||||
seq,
|
||||
itemGuid,
|
||||
inscription));
|
||||
}
|
||||
|
||||
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
|
||||
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public enum PublicWeenieFlags : uint
|
|||
{
|
||||
None = 0,
|
||||
Openable = 0x00000001,
|
||||
Inscribable = 0x00000002,
|
||||
Stuck = 0x00000004,
|
||||
Player = 0x00000008,
|
||||
Attackable = 0x00000010,
|
||||
|
|
|
|||
|
|
@ -23,18 +23,23 @@ public sealed class AppraisalUiControllerTests
|
|||
Type = ItemType.MeleeWeapon,
|
||||
Value = 1250,
|
||||
Burden = 350,
|
||||
ContainerId = 0x50000002u,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||
});
|
||||
var sent = new List<uint>();
|
||||
var inscriptions = new List<(uint ObjectId, string Text)>();
|
||||
var messages = new List<string>();
|
||||
using var interaction = NewInteraction(objects, sent);
|
||||
var combat = new CombatState();
|
||||
int shown = 0;
|
||||
int closed = 0;
|
||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
combat,
|
||||
new Spellbook(),
|
||||
inscriptions,
|
||||
messages,
|
||||
() => shown++,
|
||||
() => closed++)!;
|
||||
|
||||
|
|
@ -64,10 +69,12 @@ public sealed class AppraisalUiControllerTests
|
|||
UiField inscription = Assert.IsType<UiField>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
Assert.Contains("Remember the fallen", inscription.Text);
|
||||
Assert.False(inscription.Enabled);
|
||||
Assert.True(inscription.Editable);
|
||||
UiText signature = Assert.IsType<UiText>(
|
||||
layout.FindElement(AppraisalUiController.SignatureTextId));
|
||||
Assert.Equal("Inscribed by Tester", Assert.Single(signature.LinesProvider()).Text);
|
||||
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
||||
Assert.Empty(inscriptions);
|
||||
Assert.Empty(messages);
|
||||
|
||||
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
|
||||
Assert.Equal(1, closed);
|
||||
|
|
@ -88,12 +95,13 @@ public sealed class AppraisalUiControllerTests
|
|||
using var interaction = NewInteraction(objects, sent);
|
||||
var combat = new CombatState();
|
||||
int shown = 0;
|
||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
combat,
|
||||
new Spellbook(),
|
||||
[],
|
||||
[],
|
||||
() => shown++,
|
||||
() => { })!;
|
||||
|
||||
|
|
@ -145,12 +153,13 @@ public sealed class AppraisalUiControllerTests
|
|||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
new Spellbook(),
|
||||
[],
|
||||
[],
|
||||
() => throw new InvalidOperationException("must not show"),
|
||||
() => { })!;
|
||||
|
||||
|
|
@ -158,6 +167,281 @@ public sealed class AppraisalUiControllerTests
|
|||
Assert.Equal(0u, controller.CurrentObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyOwnedInscription_FocusEditAndBlur_SendsRetailTransactionOnce()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Sword",
|
||||
Type = ItemType.MeleeWeapon,
|
||||
ContainerId = 0x50000002u,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||
});
|
||||
var inscriptions = new List<(uint ObjectId, string Text)>();
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
inscriptions,
|
||||
[],
|
||||
() => { },
|
||||
() => { })!;
|
||||
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
|
||||
|
||||
UiField field = Assert.IsType<UiField>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
UiText signature = Assert.IsType<UiText>(
|
||||
layout.FindElement(AppraisalUiController.SignatureTextId));
|
||||
Assert.True(field.Editable);
|
||||
Assert.Equal("<Inscribe here>", field.Text);
|
||||
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
|
||||
Assert.Equal(string.Empty, field.Text);
|
||||
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
||||
foreach (char c in "For glory")
|
||||
field.InsertChar(c);
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
||||
|
||||
Assert.Equal(
|
||||
new[] { (ObjectId, "For glory") },
|
||||
inscriptions);
|
||||
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
|
||||
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
||||
Assert.Single(inscriptions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExistingInscription_ClearOnBlur_SendsEmptyAndRestoresPlaceholder()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Sword",
|
||||
Type = ItemType.MeleeWeapon,
|
||||
ContainerId = 0x50000002u,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||
});
|
||||
var inscriptions = new List<(uint ObjectId, string Text)>();
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
inscriptions,
|
||||
[],
|
||||
() => { },
|
||||
() => { })!;
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var properties = new PropertyBundle();
|
||||
properties.Strings[7u] = "Old words";
|
||||
properties.Strings[8u] = "Tester";
|
||||
Assert.True(controller.Apply(Parsed(properties)));
|
||||
|
||||
UiField field = Assert.IsType<UiField>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
UiText signature = Assert.IsType<UiText>(
|
||||
layout.FindElement(AppraisalUiController.SignatureTextId));
|
||||
field.SetText(string.Empty);
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
|
||||
|
||||
Assert.Equal(new[] { (ObjectId, string.Empty) }, inscriptions);
|
||||
Assert.Equal("<Inscribe here>", field.Text);
|
||||
Assert.Equal(string.Empty, Assert.Single(signature.LinesProvider()).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InscriptionPermissionMessages_MatchRetail()
|
||||
{
|
||||
ImportedLayout otherScribeLayout = FixtureLoader.LoadExamination();
|
||||
var otherScribeObjects = new ClientObjectTable();
|
||||
otherScribeObjects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Sword",
|
||||
ContainerId = 0x50000002u,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||
});
|
||||
var messages = new List<string>();
|
||||
using var otherInteraction = NewInteraction(otherScribeObjects, []);
|
||||
using AppraisalUiController otherController = Bind(
|
||||
otherScribeLayout,
|
||||
otherScribeObjects,
|
||||
otherInteraction,
|
||||
new CombatState(),
|
||||
[],
|
||||
messages,
|
||||
() => { },
|
||||
() => { })!;
|
||||
otherInteraction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
var otherProperties = new PropertyBundle();
|
||||
otherProperties.Strings[7u] = "Hands off";
|
||||
otherProperties.Strings[8u] = "Other";
|
||||
Assert.True(otherController.Apply(Parsed(otherProperties)));
|
||||
UiField otherField = Assert.IsType<UiField>(
|
||||
otherScribeLayout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
Assert.False(otherField.Editable);
|
||||
otherField.OnEvent(new UiEvent(
|
||||
otherField.EventId,
|
||||
otherField,
|
||||
UiEventType.Click));
|
||||
Assert.Equal("Only Other can change the inscription", Assert.Single(messages));
|
||||
|
||||
ImportedLayout unownedLayout = FixtureLoader.LoadExamination();
|
||||
var unownedObjects = new ClientObjectTable();
|
||||
unownedObjects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Sword",
|
||||
ContainerId = 0x60000000u,
|
||||
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||
});
|
||||
messages.Clear();
|
||||
using var unownedInteraction = NewInteraction(unownedObjects, []);
|
||||
using AppraisalUiController unownedController = Bind(
|
||||
unownedLayout,
|
||||
unownedObjects,
|
||||
unownedInteraction,
|
||||
new CombatState(),
|
||||
[],
|
||||
messages,
|
||||
() => { },
|
||||
() => { })!;
|
||||
unownedInteraction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
Assert.True(unownedController.Apply(Parsed(new PropertyBundle())));
|
||||
UiField unownedField = Assert.IsType<UiField>(
|
||||
unownedLayout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
unownedField.OnEvent(new UiEvent(
|
||||
unownedField.EventId,
|
||||
unownedField,
|
||||
UiEventType.Click));
|
||||
Assert.Equal(
|
||||
"Item must be in your inventory to inscribe.",
|
||||
Assert.Single(messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonInscribableItem_HidesEditorAndBackgroundReportsRetailMessage()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Component",
|
||||
ContainerId = 0x50000002u,
|
||||
});
|
||||
var messages = new List<string>();
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
messages,
|
||||
() => { },
|
||||
() => { })!;
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
|
||||
|
||||
UiField field = Assert.IsType<UiField>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
UiText signature = Assert.IsType<UiText>(
|
||||
layout.FindElement(AppraisalUiController.SignatureTextId));
|
||||
Assert.False(field.Visible);
|
||||
Assert.False(signature.Visible);
|
||||
|
||||
UiDatElement background = Assert.IsType<UiDatElement>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionBackgroundId));
|
||||
background.OnClick!.Invoke();
|
||||
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HookProfileControlsVisibilityButPublicFlagStillControlsEditing()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadExamination();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = ObjectId,
|
||||
Name = "Hooked Decoration",
|
||||
ContainerId = 0x50000002u,
|
||||
});
|
||||
var messages = new List<string>();
|
||||
using var interaction = NewInteraction(objects, []);
|
||||
using AppraisalUiController controller = Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
new CombatState(),
|
||||
[],
|
||||
messages,
|
||||
() => { },
|
||||
() => { })!;
|
||||
interaction.ExamineSelectedOrEnterMode(ObjectId);
|
||||
|
||||
AppraiseInfoParser.Parsed appraisal = Parsed(new PropertyBundle()) with
|
||||
{
|
||||
Flags = AppraiseInfoParser.IdentifyResponseFlags.HookProfile,
|
||||
HookProfile = new AppraiseInfoParser.HookProfile(
|
||||
Flags: 1u,
|
||||
ValidLocations: 0u,
|
||||
AmmoType: 0u),
|
||||
};
|
||||
Assert.True(controller.Apply(appraisal));
|
||||
|
||||
UiField field = Assert.IsType<UiField>(
|
||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||
Assert.True(field.Visible);
|
||||
Assert.False(field.Editable);
|
||||
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.Click));
|
||||
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Examination_IsAnIndependentFloatyWindow_NotSharedMainPanelContent()
|
||||
{
|
||||
Assert.False(
|
||||
RetailPanelCatalog.TryGetPanelId(WindowNames.Examination, out _));
|
||||
Assert.DoesNotContain(
|
||||
RetailPanelCatalog.MountedPanels,
|
||||
mounted => mounted.WindowName == WindowNames.Examination);
|
||||
}
|
||||
|
||||
private static AppraisalUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
ClientObjectTable objects,
|
||||
ItemInteractionController interaction,
|
||||
CombatState combat,
|
||||
List<(uint ObjectId, string Text)> inscriptions,
|
||||
List<string> messages,
|
||||
Action show,
|
||||
Action close)
|
||||
=> AppraisalUiController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
interaction,
|
||||
combat,
|
||||
new Spellbook(),
|
||||
() => "Tester",
|
||||
(objectId, text) => inscriptions.Add((objectId, text)),
|
||||
messages.Add,
|
||||
show,
|
||||
close);
|
||||
|
||||
private static ItemInteractionController NewInteraction(
|
||||
ClientObjectTable objects,
|
||||
List<uint> sent)
|
||||
|
|
|
|||
|
|
@ -94,4 +94,52 @@ public class UiFieldTests
|
|||
|
||||
Assert.Equal("5", input.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadOnlyField_RejectsMutationsButReportsClick()
|
||||
{
|
||||
int clicks = 0;
|
||||
var input = new UiField
|
||||
{
|
||||
Editable = false,
|
||||
OnReadOnlyClick = () => clicks++,
|
||||
};
|
||||
input.SetText("fixed");
|
||||
|
||||
input.InsertChar('!');
|
||||
input.Backspace();
|
||||
input.OnEvent(new UiEvent(0u, input, UiEventType.Click));
|
||||
|
||||
Assert.Equal("fixed", input.Text);
|
||||
Assert.Equal(1, clicks);
|
||||
Assert.False(input.AcceptsFocus);
|
||||
Assert.False(input.IsEditControl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiLineField_EnterAddsNewlineInsteadOfSubmitting()
|
||||
{
|
||||
int submissions = 0;
|
||||
var input = new UiField
|
||||
{
|
||||
OneLine = false,
|
||||
OnSubmit = _ => submissions++,
|
||||
};
|
||||
input.InsertChar('a');
|
||||
|
||||
input.OnEvent(new UiEvent(
|
||||
0u,
|
||||
input,
|
||||
UiEventType.KeyDown,
|
||||
Data0: (int)Silk.NET.Input.Key.Enter));
|
||||
input.OnEvent(new UiEvent(
|
||||
0u,
|
||||
input,
|
||||
UiEventType.Char,
|
||||
Data0: '\r'));
|
||||
input.InsertChar('b');
|
||||
|
||||
Assert.Equal("a\nb", input.Text);
|
||||
Assert.Equal(0, submissions);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,39 @@ public sealed class GameEventWiringTests
|
|||
Assert.False(items.Get(guid)!.Properties.Ints.ContainsKey(25u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_LiteralAceCreatureFlag_ForwardsAndUpdatesHealth()
|
||||
{
|
||||
const uint guid = 0x50000001u;
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var combat = new CombatState();
|
||||
AppraiseInfoParser.Parsed? received = null;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
combat,
|
||||
new Spellbook(),
|
||||
new ChatLog(),
|
||||
onAppraisal: appraisal => received = appraisal);
|
||||
|
||||
byte[] payload = new byte[24];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
payload.AsSpan(4),
|
||||
0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 75u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 100u);
|
||||
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload))!.Value);
|
||||
|
||||
Assert.NotNull(received);
|
||||
Assert.NotNull(received.Value.CreatureProfile);
|
||||
Assert.Equal(0.75f, combat.GetHealthPercent(guid), 3);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,27 @@ namespace AcDream.Core.Net.Tests.Messages;
|
|||
|
||||
public sealed class AppraiseInfoParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, 0x0001u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable, 0x0002u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.FloatStatsTable, 0x0004u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable, 0x0008u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.SpellBook, 0x0010u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponProfile, 0x0020u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.HookProfile, 0x0040u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorProfile, 0x0080u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, 0x0100u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield, 0x0200u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ResistEnchantmentBitfield, 0x0400u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponEnchantmentBitfield, 0x0800u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.DidStatsTable, 0x1000u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.Int64StatsTable, 0x2000u)]
|
||||
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels, 0x4000u)]
|
||||
public void IdentifyResponseFlags_MatchRetailWireValues(
|
||||
AppraiseInfoParser.IdentifyResponseFlags flag,
|
||||
uint wireValue)
|
||||
=> Assert.Equal(wireValue, (uint)flag);
|
||||
|
||||
/// <summary>
|
||||
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
|
||||
/// with (guid, flags, success) then per-flag tables.
|
||||
|
|
@ -319,6 +340,27 @@ public sealed class AppraiseInfoParserTests
|
|||
Assert.Equal(300u, c.Health);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_LiteralAceCreatureFlag_DoesNotDecodeAsWeapon()
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
bw.Write(0x50000001u);
|
||||
bw.Write(0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
|
||||
bw.Write(1u);
|
||||
bw.Write(0u);
|
||||
bw.Write(300u);
|
||||
bw.Write(400u);
|
||||
|
||||
AppraiseInfoParser.Parsed? parsed =
|
||||
AppraiseInfoParser.TryParse(ms.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.NotNull(parsed.Value.CreatureProfile);
|
||||
Assert.Null(parsed.Value.WeaponProfile);
|
||||
Assert.Equal(300u, parsed.Value.CreatureProfile.Value.Health);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -148,4 +148,42 @@ public sealed class InventoryActionsTests
|
|||
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
||||
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetInscription_UsesRetailOpcodeGuidAndCp1252String16L()
|
||||
{
|
||||
byte[] body = InventoryActions.BuildSetInscription(
|
||||
seq: 7,
|
||||
itemGuid: 0x50000A01u,
|
||||
inscription: "Café");
|
||||
|
||||
Assert.Equal(24, body.Length);
|
||||
Assert.Equal(0xF7B1u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body));
|
||||
Assert.Equal(7u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
|
||||
Assert.Equal(0x00BFu,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
|
||||
Assert.Equal(0x50000A01u,
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
|
||||
Assert.Equal(4,
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(new byte[] { 0x43, 0x61, 0x66, 0xE9 },
|
||||
body.AsSpan(18, 4).ToArray());
|
||||
Assert.Equal(new byte[] { 0, 0 }, body.AsSpan(22, 2).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSetInscription_EmptyTextPacksAlignedClearRequest()
|
||||
{
|
||||
byte[] body = InventoryActions.BuildSetInscription(
|
||||
seq: 1,
|
||||
itemGuid: 0x50000A01u,
|
||||
inscription: string.Empty);
|
||||
|
||||
Assert.Equal(20, body.Length);
|
||||
Assert.Equal(0,
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(16)));
|
||||
Assert.Equal(new byte[] { 0, 0 }, body.AsSpan(18, 2).ToArray());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,4 +60,19 @@ public sealed class WorldSessionInventoryActionTests
|
|||
InventoryActions.BuildGiveObjectRequest(1, 0xAAu, 0xBBu, 3u),
|
||||
captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendSetInscription_UsesNextSequenceAndExactBuilderBytes()
|
||||
{
|
||||
using var session = new WorldSession(
|
||||
new IPEndPoint(IPAddress.Loopback, 65000));
|
||||
byte[]? captured = null;
|
||||
session.GameActionCapture = body => captured = body;
|
||||
|
||||
session.SendSetInscription(0xAAu, "Remember.");
|
||||
|
||||
Assert.Equal(
|
||||
InventoryActions.BuildSetInscription(1, 0xAAu, "Remember."),
|
||||
captured);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue