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,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||||
Release builds; the current interaction-completion baseline passes 3,488 App
|
Release builds; the appraisal corrective implementation passes 3,496 App
|
||||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
tests / 3 skips and 7,913 complete-solution tests / 5 skips.
|
||||||
|
|
||||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
spell-bar overflow, status Use/Assess, assessment information,
|
spell-bar overflow, status Use/Assess, assessment information,
|
||||||
equipped-child picking, vendor browsing, and authoritative vendor
|
equipped-child picking, vendor browsing, and authoritative vendor
|
||||||
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
||||||
user-accepted. Slice 3's retail examination request/panel implementation is
|
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||||
code-complete and awaits its connected visual gate.
|
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
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||||
|
|
|
||||||
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,
|
user-gated. Deterministic world-lifecycle automation protects fresh login,
|
||||||
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
|
||||||
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
|
||||||
Release builds; the current interaction-completion baseline passes 3,488 App
|
Release builds; the appraisal corrective implementation passes 3,496 App
|
||||||
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
|
tests / 3 skips and 7,913 complete-solution tests / 5 skips.
|
||||||
|
|
||||||
**Current work order:** the six-slice pre-M4 world-interaction completion
|
**Current work order:** the six-slice pre-M4 world-interaction completion
|
||||||
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
|
||||||
spell-bar overflow, status Use/Assess, assessment information,
|
spell-bar overflow, status Use/Assess, assessment information,
|
||||||
equipped-child picking, vendor browsing, and authoritative vendor
|
equipped-child picking, vendor browsing, and authoritative vendor
|
||||||
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
|
||||||
user-accepted. Slice 3's retail examination request/panel implementation is
|
user-accepted. Slice 3's first connected gate exposed and corrected the
|
||||||
code-complete and awaits its connected visual gate.
|
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
|
**Structural prerequisite before new M4 subsystem work:** all eight
|
||||||
behavior-preserving `GameWindow` decomposition slices and the automated
|
behavior-preserving `GameWindow` decomposition slices and the automated
|
||||||
|
|
|
||||||
|
|
@ -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
|
## #235 — Capped/RDP jump presentation aliases the 30 Hz object clock
|
||||||
|
|
||||||
**Status:** OPEN
|
**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-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
|
||||||
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
|
||||||
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
|
||||||
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/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-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||||
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ ports the optional public shared-cooldown metadata, the authoritative
|
||||||
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
|
||||||
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
|
||||||
presentation; its connected gate passed. The existing magnifying-glass Assess
|
presentation; its connected gate passed. The existing magnifying-glass Assess
|
||||||
command was already retail-faithful. Slice 3 is now code-complete: the complete
|
command was already retail-faithful. Slice 3's first connected gate exposed
|
||||||
appraisal response reaches a balanced pending/current request owner and the
|
three deeper defects, now corrected: the response enum mislabeled ACE's
|
||||||
imported 310 x 400 examination layout through the canonical shared main-panel
|
creature bit `0x0100` and dropped monster packets while retaining the busy
|
||||||
host. Item, creature, and character views, authored scrollbars, first-response
|
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
|
||||||
visibility, and visible-combat refresh are pinned; the connected examination
|
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.
|
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,
|
adjacent cooldown follow-up now preserves the item's shared group/duration,
|
||||||
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
|
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
|
||||||
DAT-authored radial steps across all item-slot surfaces; its connected gate
|
DAT-authored radial steps across all item-slot surfaces; its connected gate
|
||||||
passed. The pre-existing Assess command has been reconciled against retail and
|
passed. The pre-existing Assess command has been reconciled against retail.
|
||||||
Slice 3 is code-complete: strict full appraisal parsing, balanced
|
Slice 3's first connected gate found and corrected the real remaining defects:
|
||||||
pending/current request ownership, and LayoutDesc `0x2100006B` item,
|
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
|
||||||
creature, and character views now live in the canonical shared main-panel
|
appraisal busy reference held, retail's examination UI had been mounted as a
|
||||||
host. Its connected examination gate is next.
|
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:
|
Carried:
|
||||||
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
|
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
|
||||||
zero-useability, carried direct-use, AutoWear correction, and the requested
|
zero-useability, carried direct-use, AutoWear correction, and the requested
|
||||||
shared item-cooldown follow-up passed their connected gates. The existing
|
shared item-cooldown follow-up passed their connected gates. Slice 3's first
|
||||||
retail Assess command has been reconciled and Slice 3's examination panel is
|
connected gate exposed an incorrect IdentifyResponse flag table, a non-retail
|
||||||
code-complete; its connected visual gate is next.
|
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.
|
**Milestone:** M4 prerequisite/preamble.
|
||||||
**Architecture:** retained gameplay UI over shared selection, object, and
|
**Architecture:** retained gameplay UI over shared selection, object, and
|
||||||
interaction state. `GameWindow` remains a composition/callback shell.
|
interaction state. `GameWindow` remains a composition/callback shell.
|
||||||
|
|
@ -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
|
2. The status bar's hand and magnifying-glass controls invoke the same Use and
|
||||||
Assess commands as their keyboard paths.
|
Assess commands as their keyboard paths.
|
||||||
3. Assessing a creature, player, NPC, or object opens its retail information in
|
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
|
4. World picking can resolve visible equipped children, such as a character's
|
||||||
wielded weapon, while selection markers remain anchored to the picked child.
|
wielded weapon, while selection markers remain anchored to the picked child.
|
||||||
5. Vendor use opens the authored vendor surface, publishes its inventory, and
|
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 |
|
| 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 |
|
| 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 |
|
| 4 | Equipped-child world picking | pure world-query/picking policy plus presentation anchor |
|
||||||
| 5 | Vendor browse lifecycle | vendor session/controller plus authored panel |
|
| 5 | Vendor browse lifecycle | vendor session/controller plus authored panel |
|
||||||
| 6 | Vendor transactions | server-authoritative buy/sell command and reconciliation owner |
|
| 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
|
latest pending GUID, stale replies are rejected, the accepted reply becomes
|
||||||
the current appraisal, and combat-time creature/player refreshes occur every
|
the current appraisal, and combat-time creature/player refreshes occur every
|
||||||
0.75 seconds only while the examination window is visible. Complete
|
0.75 seconds only while the examination window is visible. Complete
|
||||||
`IdentifyObjectResponse` parsing now includes the three-word HookProfile and
|
`IdentifyObjectResponse` parsing now uses ACE/retail's exact flag values and
|
||||||
rejects truncated gated payloads rather than continuing from a corrupt cursor.
|
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
|
LayoutDesc `0x2100006B`, root `0x100005F2`, supplies the complete 310 x 400
|
||||||
floaty chrome, title, item/creature/character subviews, close control, and
|
floaty chrome, title, item/creature/character subviews, close control, and
|
||||||
authored scrollbars. The panel participates in the canonical one-active
|
authored scrollbars. `gmFloatyExaminationUI` is an independent top-level
|
||||||
main-panel geometry/lifecycle rather than owning a parallel location. Basic
|
window, so Inventory/Skills/Spellbook no longer replace it or inherit its
|
||||||
item, creature, and player reports are live; exact 3D preview/specialized
|
geometry. The imported multiline inscription field now ports
|
||||||
detail regions, conditional inscription editing, and selection-follow remain
|
`ItemExamineUI`'s public/hook inscribability, inventory ownership and
|
||||||
the narrowed AP-110 residual. Research:
|
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).
|
[`../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
|
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
|
Release solution build with 17 pre-existing tracked test warnings, 3,496 App
|
||||||
tests / 3 skips, and 7,885 complete-solution tests / 5 skips.
|
tests / 3 skips, and 7,913 complete-solution tests / 5 skips.
|
||||||
|
|
||||||
## Slice 1 — spell-bar overflow arrows
|
## 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
|
3. `AppraisalUiController` owns response acceptance, item/creature/character
|
||||||
subview selection, report projection, scroll preservation, first-response
|
subview selection, report projection, scroll preservation, first-response
|
||||||
visibility, and visible-combat refresh.
|
visibility, and visible-combat refresh.
|
||||||
4. `RetailUiRuntime` imports the retail layout and registers it with
|
4. `RetailUiRuntime` imports and registers retail's
|
||||||
`RetailPanelUiController`, so Inventory, Skills, Spellbook, effects, and
|
`gmFloatyExaminationUI` as its own top-level window. It deliberately does
|
||||||
Examination remain views of the same movable/resizable main panel.
|
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
|
5. Network workers deliver immutable parsed data through the existing
|
||||||
session router; retained state changes remain on the update thread.
|
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
|
### Connected gate
|
||||||
|
|
||||||
- Select an item and click the magnifying glass (or press Assess): the shared
|
- Select an item and click the magnifying glass (or press Assess): a separate
|
||||||
panel should show its name, available properties, descriptions,
|
floaty window should show its name, available properties, descriptions,
|
||||||
inscription/signature, retail chrome, and working scrollbar.
|
inscription/signature, retail chrome, and working scrollbars.
|
||||||
- Assess a monster, NPC, and player: the correct creature/character page
|
- Assess a monster, NPC, and player: the correct creature/character page
|
||||||
should appear and the busy cursor should clear.
|
should appear and the busy cursor should clear.
|
||||||
- In combat, leave a creature assessment open long enough to observe a health
|
- In combat, leave a creature assessment open long enough to observe a health
|
||||||
refresh; closing it or opening another shared panel must stop refreshes.
|
refresh; closing the examination window must stop refreshes.
|
||||||
- Move/resize Examination, switch to Inventory or Skills, and confirm the
|
- Keep Examination open while opening/moving Inventory or Skills and confirm
|
||||||
shared rectangle is retained.
|
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`
|
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
|
||||||
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
|
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
|
||||||
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
|
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
|
||||||
|
- `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0`
|
||||||
|
- `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230`
|
||||||
|
- `ItemExamineUI::SetInscriptionEditableState @ 0x004AC720`
|
||||||
|
- `ItemExamineUI::SetInscription @ 0x004AE2F0`
|
||||||
|
- `ItemExamineUI::HandleInscriptionLosingFocus @ 0x004AE620`
|
||||||
- `gmFloatyExaminationUI::ResizeTo @ 0x004D4470`
|
- `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`
|
- Retail LayoutDesc `0x2100006B`, root `0x100005F2`
|
||||||
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
|
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
|
||||||
- ACE:
|
- ACE:
|
||||||
|
- `IdentifyResponseFlags`
|
||||||
- `GameEventIdentifyObjectResponse`
|
- `GameEventIdentifyObjectResponse`
|
||||||
- `AppraiseInfo.Write`
|
- `AppraiseInfo.Write`
|
||||||
- `HookProfile.Write`
|
- `HookProfile.Write`
|
||||||
|
- `GameActionSetInscription`
|
||||||
|
- `Player.HandleActionSetInscription`
|
||||||
- ACViewer's `HookAppraisalProfile` representation, used as a second
|
- ACViewer's `HookAppraisalProfile` representation, used as a second
|
||||||
interpretation check for the three-field hook profile.
|
interpretation check for the three-field hook profile.
|
||||||
|
|
||||||
|
|
@ -63,6 +75,30 @@ for the current pending GUID releases that one reference.
|
||||||
|
|
||||||
## IdentifyObjectResponse packet order
|
## 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
|
```text
|
||||||
read guid
|
read guid
|
||||||
read flags
|
read flags
|
||||||
|
|
@ -178,16 +214,126 @@ same object preserves the user's position.
|
||||||
|
|
||||||
The LayoutDesc root already contains retail's complete floaty window chrome,
|
The LayoutDesc root already contains retail's complete floaty window chrome,
|
||||||
close control, item/creature/character/spell subviews, and scrollbars.
|
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`
|
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
|
||||||
and persists width/height; it adds no special resize algorithm.
|
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,
|
```text
|
||||||
authored outer layout, basic report content, scrollbar ownership, and combat
|
SetInscription(profile):
|
||||||
refresh. Retail's exact 3D object/creature preview, all specialized
|
reset inscription field to its unfocused state
|
||||||
`ItemExamineUI` detail branches, selection-follow preference, and
|
clear displayed text, signature, old inscription, scribe name/account
|
||||||
`SetInscriptionEditableState` plus inscription submission remain explicit
|
|
||||||
AP-110 work. Until that permission/write transaction exists, the imported
|
inscribable = object.PublicWeenieDesc has BF_INSCRIBABLE
|
||||||
inscription field is display-only rather than accepting edits that cannot be
|
if object is a hook:
|
||||||
sent.
|
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,
|
type,
|
||||||
context,
|
context,
|
||||||
accepted)),
|
accepted)),
|
||||||
|
Appraisal: new AppraisalRuntimeBindings(
|
||||||
|
characterSheet.CharacterName,
|
||||||
|
(item, inscription) =>
|
||||||
|
late.Session.CurrentSession?.SendSetInscription(
|
||||||
|
item,
|
||||||
|
inscription),
|
||||||
|
text => d.Chat.OnSystemMessage(text, 0x1Au)),
|
||||||
StackSplitQuantity: d.StackSplitQuantity,
|
StackSplitQuantity: d.StackSplitQuantity,
|
||||||
Plugins: d.UiRegistry,
|
Plugins: d.UiRegistry,
|
||||||
Persistence: persistence,
|
Persistence: persistence,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
public const uint ItemPanelId = 0x1000012Eu;
|
public const uint ItemPanelId = 0x1000012Eu;
|
||||||
public const uint ItemTextId = 0x1000013Cu;
|
public const uint ItemTextId = 0x1000013Cu;
|
||||||
public const uint ItemScrollbarId = 0x1000013Du;
|
public const uint ItemScrollbarId = 0x1000013Du;
|
||||||
|
public const uint InscriptionBackgroundId = 0x10000137u;
|
||||||
public const uint InscriptionTextId = 0x1000013Eu;
|
public const uint InscriptionTextId = 0x1000013Eu;
|
||||||
public const uint SignatureTextId = 0x1000013Fu;
|
public const uint SignatureTextId = 0x1000013Fu;
|
||||||
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
public const uint InscriptionScrollbarId = 0x1000046Eu;
|
||||||
|
|
@ -38,6 +39,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly ItemInteractionController _interaction;
|
private readonly ItemInteractionController _interaction;
|
||||||
private readonly CombatState _combat;
|
private readonly CombatState _combat;
|
||||||
private readonly Spellbook _spellbook;
|
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 Action _show;
|
||||||
private readonly UiElement _itemPanel;
|
private readonly UiElement _itemPanel;
|
||||||
private readonly UiElement _creaturePanel;
|
private readonly UiElement _creaturePanel;
|
||||||
|
|
@ -47,6 +51,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private readonly UiText? _inscriptionText;
|
private readonly UiText? _inscriptionText;
|
||||||
private readonly UiField? _inscriptionField;
|
private readonly UiField? _inscriptionField;
|
||||||
private readonly UiText? _signature;
|
private readonly UiText? _signature;
|
||||||
|
private readonly UiDatElement? _inscriptionBackground;
|
||||||
private readonly UiButton? _close;
|
private readonly UiButton? _close;
|
||||||
private AppraisalView _activeView;
|
private AppraisalView _activeView;
|
||||||
private uint _itemObjectId;
|
private uint _itemObjectId;
|
||||||
|
|
@ -56,6 +61,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
private string _itemReport = string.Empty;
|
private string _itemReport = string.Empty;
|
||||||
private string _inscriptionValue = string.Empty;
|
private string _inscriptionValue = string.Empty;
|
||||||
private string _signatureValue = string.Empty;
|
private string _signatureValue = string.Empty;
|
||||||
|
private string _scribeName = string.Empty;
|
||||||
|
private string _oldInscription = string.Empty;
|
||||||
|
private bool _presentationInscribable;
|
||||||
private double _refreshElapsed;
|
private double _refreshElapsed;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -65,6 +73,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
ItemInteractionController interaction,
|
ItemInteractionController interaction,
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
Func<string> playerName,
|
||||||
|
Action<uint, string> sendSetInscription,
|
||||||
|
Action<string> systemMessage,
|
||||||
Action show,
|
Action show,
|
||||||
Action close,
|
Action close,
|
||||||
UiElement itemPanel,
|
UiElement itemPanel,
|
||||||
|
|
@ -77,6 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_interaction = interaction;
|
_interaction = interaction;
|
||||||
_combat = combat;
|
_combat = combat;
|
||||||
_spellbook = spellbook;
|
_spellbook = spellbook;
|
||||||
|
_playerName = playerName;
|
||||||
|
_sendSetInscription = sendSetInscription;
|
||||||
|
_systemMessage = systemMessage;
|
||||||
_show = show;
|
_show = show;
|
||||||
_itemPanel = itemPanel;
|
_itemPanel = itemPanel;
|
||||||
_creaturePanel = creaturePanel;
|
_creaturePanel = creaturePanel;
|
||||||
|
|
@ -86,6 +100,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
|
||||||
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
|
||||||
_signature = layout.FindElement(SignatureTextId) as UiText;
|
_signature = layout.FindElement(SignatureTextId) as UiText;
|
||||||
|
_inscriptionBackground =
|
||||||
|
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
|
||||||
_close = layout.FindElement(CloseId) as UiButton;
|
_close = layout.FindElement(CloseId) as UiButton;
|
||||||
|
|
||||||
_title.LinesProvider = () =>
|
_title.LinesProvider = () =>
|
||||||
|
|
@ -99,17 +115,26 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
if (_inscriptionField is not null)
|
if (_inscriptionField is not null)
|
||||||
{
|
{
|
||||||
_inscriptionField.SetText(string.Empty);
|
_inscriptionField.SetText(string.Empty);
|
||||||
// Retail conditionally enables this field only after its ownership
|
_inscriptionField.ClearOnSubmit = false;
|
||||||
// and inscription-permission checks. That write transaction remains
|
_inscriptionField.RecordHistory = false;
|
||||||
// tracked by AP-110; keep the imported field display-only until the
|
_inscriptionField.Editable = false;
|
||||||
// matching SetInscriptionEditableState/submit path is present.
|
_inscriptionField.Selectable = false;
|
||||||
_inscriptionField.Enabled = 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)
|
if (_signature is not null)
|
||||||
_signature.LinesProvider = () =>
|
_signature.LinesProvider = () =>
|
||||||
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
|
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
|
||||||
if (_close is not null)
|
if (_close is not null)
|
||||||
_close.OnClick = close;
|
_close.OnClick = close;
|
||||||
|
if (_inscriptionBackground is not null)
|
||||||
|
{
|
||||||
|
_inscriptionBackground.OnClick = ReportInscriptionUnavailable;
|
||||||
|
_inscriptionBackground.ClickThrough = false;
|
||||||
|
}
|
||||||
|
|
||||||
SetActiveView(AppraisalView.Item);
|
SetActiveView(AppraisalView.Item);
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +148,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
ItemInteractionController interaction,
|
ItemInteractionController interaction,
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
|
Func<string> playerName,
|
||||||
|
Action<uint, string> sendSetInscription,
|
||||||
|
Action<string> systemMessage,
|
||||||
Action show,
|
Action show,
|
||||||
Action close)
|
Action close)
|
||||||
{
|
{
|
||||||
|
|
@ -131,6 +159,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
ArgumentNullException.ThrowIfNull(interaction);
|
ArgumentNullException.ThrowIfNull(interaction);
|
||||||
ArgumentNullException.ThrowIfNull(combat);
|
ArgumentNullException.ThrowIfNull(combat);
|
||||||
ArgumentNullException.ThrowIfNull(spellbook);
|
ArgumentNullException.ThrowIfNull(spellbook);
|
||||||
|
ArgumentNullException.ThrowIfNull(playerName);
|
||||||
|
ArgumentNullException.ThrowIfNull(sendSetInscription);
|
||||||
|
ArgumentNullException.ThrowIfNull(systemMessage);
|
||||||
ArgumentNullException.ThrowIfNull(show);
|
ArgumentNullException.ThrowIfNull(show);
|
||||||
ArgumentNullException.ThrowIfNull(close);
|
ArgumentNullException.ThrowIfNull(close);
|
||||||
|
|
||||||
|
|
@ -146,6 +177,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
interaction,
|
interaction,
|
||||||
combat,
|
combat,
|
||||||
spellbook,
|
spellbook,
|
||||||
|
playerName,
|
||||||
|
sendSetInscription,
|
||||||
|
systemMessage,
|
||||||
show,
|
show,
|
||||||
close,
|
close,
|
||||||
itemPanel,
|
itemPanel,
|
||||||
|
|
@ -228,7 +262,19 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_itemReport = string.Empty;
|
_itemReport = string.Empty;
|
||||||
_inscriptionValue = string.Empty;
|
_inscriptionValue = string.Empty;
|
||||||
_inscriptionField?.SetText(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;
|
_signatureValue = string.Empty;
|
||||||
|
if (_signature is not null)
|
||||||
|
_signature.Visible = false;
|
||||||
|
_scribeName = string.Empty;
|
||||||
|
_oldInscription = string.Empty;
|
||||||
|
_presentationInscribable = false;
|
||||||
_itemObjectId = 0;
|
_itemObjectId = 0;
|
||||||
_creatureObjectId = 0;
|
_creatureObjectId = 0;
|
||||||
_characterObjectId = 0;
|
_characterObjectId = 0;
|
||||||
|
|
@ -246,19 +292,158 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
obj,
|
obj,
|
||||||
appraisal,
|
appraisal,
|
||||||
ResolveSpellName);
|
ResolveSpellName);
|
||||||
_inscriptionValue = GetString(appraisal.Properties, 7u);
|
SetInscription(obj, appraisal);
|
||||||
_inscriptionField?.SetText(_inscriptionValue);
|
|
||||||
string scribe = GetString(appraisal.Properties, 8u);
|
|
||||||
_signatureValue = string.IsNullOrWhiteSpace(scribe)
|
|
||||||
? string.Empty
|
|
||||||
: $"Inscribed by {scribe}";
|
|
||||||
if (newlySelected)
|
if (newlySelected)
|
||||||
{
|
{
|
||||||
_itemText.Scroll.SetScrollY(0);
|
_itemText.Scroll.SetScrollY(0);
|
||||||
_inscriptionText?.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(
|
private void ApplyCreature(
|
||||||
ClientObject obj,
|
ClientObject obj,
|
||||||
AppraiseInfoParser.Parsed appraisal,
|
AppraiseInfoParser.Parsed appraisal,
|
||||||
|
|
@ -411,10 +596,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
if (_close is not null)
|
if (_close is not null)
|
||||||
_close.OnClick = 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>())
|
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
|
||||||
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|
||||||
|| (_inscriptionText is not null
|
|| (_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;
|
scrollbar.Model = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,6 @@ public static class RetailPanelCatalog
|
||||||
public const uint Character = 11u;
|
public const uint Character = 11u;
|
||||||
public const uint Magic = 13u;
|
public const uint Magic = 13u;
|
||||||
public const uint Vitae = 15u;
|
public const uint Vitae = 15u;
|
||||||
/// <summary>
|
|
||||||
/// Private retained-host identity for retail's floating examination UI.
|
|
||||||
/// The original opens it by notice rather than a gmPanelUI numeric child,
|
|
||||||
/// so this value is deliberately outside the DAT panel-id range.
|
|
||||||
/// </summary>
|
|
||||||
public const uint Examination = 0x8000_0001u;
|
|
||||||
|
|
||||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||||
{
|
{
|
||||||
|
|
@ -34,7 +28,6 @@ public static class RetailPanelCatalog
|
||||||
(Character, WindowNames.Character),
|
(Character, WindowNames.Character),
|
||||||
(Magic, WindowNames.Spellbook),
|
(Magic, WindowNames.Spellbook),
|
||||||
(Vitae, WindowNames.Vitae),
|
(Vitae, WindowNames.Vitae),
|
||||||
(Examination, WindowNames.Examination),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ public sealed record RetailUiCursorBindings(
|
||||||
public sealed record ConfirmationRuntimeBindings(
|
public sealed record ConfirmationRuntimeBindings(
|
||||||
Action<uint, uint, bool> SendResponse);
|
Action<uint, uint, bool> SendResponse);
|
||||||
|
|
||||||
|
public sealed record AppraisalRuntimeBindings(
|
||||||
|
Func<string> PlayerName,
|
||||||
|
Action<uint, string> SendSetInscription,
|
||||||
|
Action<string> DisplaySystemMessage);
|
||||||
|
|
||||||
public sealed record RetailUiRuntimeBindings(
|
public sealed record RetailUiRuntimeBindings(
|
||||||
UiHost Host,
|
UiHost Host,
|
||||||
RetailUiAssets Assets,
|
RetailUiAssets Assets,
|
||||||
|
|
@ -177,6 +182,7 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
ExternalContainerRuntimeBindings ExternalContainer,
|
ExternalContainerRuntimeBindings ExternalContainer,
|
||||||
RetailUiCursorBindings Cursor,
|
RetailUiCursorBindings Cursor,
|
||||||
ConfirmationRuntimeBindings Confirmations,
|
ConfirmationRuntimeBindings Confirmations,
|
||||||
|
AppraisalRuntimeBindings Appraisal,
|
||||||
StackSplitQuantityState StackSplitQuantity,
|
StackSplitQuantityState StackSplitQuantity,
|
||||||
BufferedUiRegistry? Plugins,
|
BufferedUiRegistry? Plugins,
|
||||||
RetailUiPersistenceBindings? Persistence,
|
RetailUiPersistenceBindings? Persistence,
|
||||||
|
|
@ -431,7 +437,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
ResetSessionDialogs();
|
ResetSessionDialogs();
|
||||||
AppraisalController?.ResetSession();
|
AppraisalController?.ResetSession();
|
||||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
|
Host.HideWindow(WindowNames.Examination);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateCursor(IEnumerable<IMouse> mice)
|
public void UpdateCursor(IEnumerable<IMouse> mice)
|
||||||
|
|
@ -971,9 +977,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Inventory.ItemInteraction,
|
_bindings.Inventory.ItemInteraction,
|
||||||
_bindings.Combat.State,
|
_bindings.Combat.State,
|
||||||
_bindings.Magic.Spellbook,
|
_bindings.Magic.Spellbook,
|
||||||
show: () => _panelUi.SetPanelVisibility(
|
_bindings.Appraisal.PlayerName,
|
||||||
RetailPanelCatalog.Examination,
|
_bindings.Appraisal.SendSetInscription,
|
||||||
visible: true),
|
_bindings.Appraisal.DisplaySystemMessage,
|
||||||
|
show: () => Host.ShowWindow(WindowNames.Examination),
|
||||||
close: () => CloseWindow(WindowNames.Examination));
|
close: () => CloseWindow(WindowNames.Examination));
|
||||||
if (controller is null)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
|
|
@ -984,7 +991,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
AppraisalController = controller;
|
AppraisalController = controller;
|
||||||
UiElement root = layout.Root;
|
UiElement root = layout.Root;
|
||||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
_ = RetailWindowFrame.Mount(
|
||||||
Host.Root,
|
Host.Root,
|
||||||
root,
|
root,
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
|
|
@ -1006,10 +1013,6 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
});
|
});
|
||||||
_panelUi.RegisterMainPanel(
|
|
||||||
RetailPanelCatalog.Examination,
|
|
||||||
WindowNames.Examination,
|
|
||||||
handle);
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
"[M4] retail examination window from LayoutDesc 0x2100006B.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ using System.Numerics;
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
/// <summary>
|
/// <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
|
/// (<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>)
|
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
|
||||||
/// as stubs for future item-window use.
|
/// as stubs for future item-window use.
|
||||||
|
|
@ -22,6 +22,8 @@ namespace AcDream.App.UI;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class UiField : UiElement
|
public sealed class UiField : UiElement
|
||||||
{
|
{
|
||||||
|
private readonly record struct WrappedLine(int Start, int Length, string Text);
|
||||||
|
|
||||||
public uint ElementId { get; set; }
|
public uint ElementId { get; set; }
|
||||||
public UiDatFont? DatFont { get; set; }
|
public UiDatFont? DatFont { get; set; }
|
||||||
public AcDream.App.Rendering.BitmapFont? Font { 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 bool RecordHistory { get; set; } = true;
|
||||||
public Func<char, bool>? CharacterFilter { get; set; }
|
public Func<char, bool>? CharacterFilter { get; set; }
|
||||||
public bool SelectAllOnFocus { 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).
|
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
|
||||||
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
|
/// 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 _selecting; // mouse drag in progress
|
||||||
private bool _preserveFocusSelectionOnMouseDown;
|
private bool _preserveFocusSelectionOnMouseDown;
|
||||||
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
|
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).
|
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
|
||||||
private Silk.NET.Input.Key? _repeatKey;
|
private Silk.NET.Input.Key? _repeatKey;
|
||||||
|
|
@ -94,7 +124,12 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
public void InsertChar(char c)
|
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();
|
DeleteSelection();
|
||||||
if (_text.Length >= MaxCharacters) return;
|
if (_text.Length >= MaxCharacters) return;
|
||||||
_text = _text.Insert(_caret, c.ToString());
|
_text = _text.Insert(_caret, c.ToString());
|
||||||
|
|
@ -104,6 +139,7 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
public void Backspace()
|
public void Backspace()
|
||||||
{
|
{
|
||||||
|
if (!Editable) return;
|
||||||
if (DeleteSelection()) return;
|
if (DeleteSelection()) return;
|
||||||
if (_caret == 0) return;
|
if (_caret == 0) return;
|
||||||
_text = _text.Remove(_caret - 1, 1);
|
_text = _text.Remove(_caret - 1, 1);
|
||||||
|
|
@ -112,6 +148,7 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
public void DeleteForward()
|
public void DeleteForward()
|
||||||
{
|
{
|
||||||
|
if (!Editable) return;
|
||||||
if (DeleteSelection()) return;
|
if (DeleteSelection()) return;
|
||||||
if (_caret >= _text.Length) return;
|
if (_caret >= _text.Length) return;
|
||||||
_text = _text.Remove(_caret, 1);
|
_text = _text.Remove(_caret, 1);
|
||||||
|
|
@ -184,6 +221,7 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
private void CutSelection()
|
private void CutSelection()
|
||||||
{
|
{
|
||||||
|
if (!Editable) return;
|
||||||
if (!HasSelection) return;
|
if (!HasSelection) return;
|
||||||
CopySelection();
|
CopySelection();
|
||||||
DeleteSelection();
|
DeleteSelection();
|
||||||
|
|
@ -192,15 +230,33 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
private void Paste()
|
private void Paste()
|
||||||
{
|
{
|
||||||
|
if (!Editable) return;
|
||||||
if (Keyboard is null) return;
|
if (Keyboard is null) return;
|
||||||
string clip = Keyboard.ClipboardText ?? "";
|
string clip = Keyboard.ClipboardText ?? "";
|
||||||
if (clip.Length == 0) return;
|
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);
|
var sb = new System.Text.StringBuilder(clip.Length);
|
||||||
|
bool previousCr = false;
|
||||||
foreach (char ch in clip)
|
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
|
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;
|
if (sb.Length == 0) return;
|
||||||
|
|
||||||
DeleteSelection();
|
DeleteSelection();
|
||||||
|
|
@ -216,6 +272,7 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
public void Submit()
|
public void Submit()
|
||||||
{
|
{
|
||||||
|
if (!Editable) return;
|
||||||
var t = _text;
|
var t = _text;
|
||||||
if (t.Trim().Length == 0)
|
if (t.Trim().Length == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -314,6 +371,12 @@ public sealed class UiField : UiElement
|
||||||
}
|
}
|
||||||
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
|
||||||
|
|
||||||
|
if (!OneLine)
|
||||||
|
{
|
||||||
|
DrawMultiLine(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||||
float ty = (Height - lh) * 0.5f;
|
float ty = (Height - lh) * 0.5f;
|
||||||
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
float visibleW = MathF.Max(1f, Width - 2f * Padding);
|
||||||
|
|
@ -370,11 +433,198 @@ public sealed class UiField : UiElement
|
||||||
return 0f;
|
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 ──────────────────────────────────────────────────────
|
// ── Auto-repeat ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
protected override void OnTick(double deltaSeconds)
|
protected override void OnTick(double deltaSeconds)
|
||||||
{
|
{
|
||||||
if (_repeatKey is not { } k) return;
|
if (!Editable || _repeatKey is not { } k) return;
|
||||||
_repeatTimer -= deltaSeconds;
|
_repeatTimer -= deltaSeconds;
|
||||||
if (_repeatTimer > 0) return;
|
if (_repeatTimer > 0) return;
|
||||||
_repeatTimer = RepeatRate;
|
_repeatTimer = RepeatRate;
|
||||||
|
|
@ -425,8 +675,18 @@ public sealed class UiField : UiElement
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case UiEventType.Char:
|
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;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case UiEventType.MouseDown:
|
case UiEventType.MouseDown:
|
||||||
if (_preserveFocusSelectionOnMouseDown)
|
if (_preserveFocusSelectionOnMouseDown)
|
||||||
|
|
@ -435,12 +695,13 @@ public sealed class UiField : UiElement
|
||||||
_selecting = false;
|
_selecting = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
_caret = HitCharX(e.Data1);
|
_caret = HitChar(e.Data1, e.Data2);
|
||||||
_selAnchor = Selectable ? _caret : null;
|
_selAnchor = Selectable ? _caret : null;
|
||||||
_selecting = Selectable;
|
_selecting = Selectable;
|
||||||
return true;
|
return true;
|
||||||
case UiEventType.MouseMove:
|
case UiEventType.MouseMove:
|
||||||
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
|
if (Selectable && _selecting)
|
||||||
|
_caret = HitChar(e.Data1, e.Data2);
|
||||||
return true;
|
return true;
|
||||||
case UiEventType.MouseUp:
|
case UiEventType.MouseUp:
|
||||||
_selecting = false;
|
_selecting = false;
|
||||||
|
|
@ -452,6 +713,8 @@ public sealed class UiField : UiElement
|
||||||
|
|
||||||
case UiEventType.KeyDown:
|
case UiEventType.KeyDown:
|
||||||
{
|
{
|
||||||
|
if (!Editable)
|
||||||
|
return true;
|
||||||
var key = (Silk.NET.Input.Key)e.Data0;
|
var key = (Silk.NET.Input.Key)e.Data0;
|
||||||
if (CtrlHeld())
|
if (CtrlHeld())
|
||||||
{
|
{
|
||||||
|
|
@ -470,6 +733,12 @@ public sealed class UiField : UiElement
|
||||||
{
|
{
|
||||||
case Silk.NET.Input.Key.Enter:
|
case Silk.NET.Input.Key.Enter:
|
||||||
case Silk.NET.Input.Key.KeypadEnter:
|
case Silk.NET.Input.Key.KeypadEnter:
|
||||||
|
if (!OneLine)
|
||||||
|
{
|
||||||
|
InsertChar('\n');
|
||||||
|
_suppressNextNewlineChar = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Submit();
|
Submit();
|
||||||
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -484,6 +753,20 @@ public sealed class UiField : UiElement
|
||||||
}
|
}
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,19 @@ public static class AppraiseInfoParser
|
||||||
{
|
{
|
||||||
None = 0x0000_0000,
|
None = 0x0000_0000,
|
||||||
IntStatsTable = 0x0000_0001,
|
IntStatsTable = 0x0000_0001,
|
||||||
Int64StatsTable = 0x0000_0002,
|
BoolStatsTable = 0x0000_0002,
|
||||||
BoolStatsTable = 0x0000_0004,
|
FloatStatsTable = 0x0000_0004,
|
||||||
FloatStatsTable = 0x0000_0008,
|
StringStatsTable = 0x0000_0008,
|
||||||
StringStatsTable = 0x0000_0010,
|
SpellBook = 0x0000_0010,
|
||||||
DidStatsTable = 0x0000_0020,
|
WeaponProfile = 0x0000_0020,
|
||||||
SpellBook = 0x0000_0040,
|
HookProfile = 0x0000_0040,
|
||||||
ArmorProfile = 0x0000_0080,
|
ArmorProfile = 0x0000_0080,
|
||||||
WeaponProfile = 0x0000_0100,
|
CreatureProfile = 0x0000_0100,
|
||||||
HookProfile = 0x0000_0200,
|
ArmorEnchantmentBitfield = 0x0000_0200,
|
||||||
ArmorEnchantmentBitfield = 0x0000_0400,
|
ResistEnchantmentBitfield= 0x0000_0400,
|
||||||
WeaponEnchantmentBitfield= 0x0000_0800,
|
WeaponEnchantmentBitfield= 0x0000_0800,
|
||||||
ResistEnchantmentBitfield= 0x0000_1000,
|
DidStatsTable = 0x0000_1000,
|
||||||
CreatureProfile = 0x0000_2000,
|
Int64StatsTable = 0x0000_2000,
|
||||||
ArmorLevels = 0x0000_4000,
|
ArmorLevels = 0x0000_4000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public static class InventoryActions
|
||||||
public const uint GetAndWieldItemOpcode = 0x001Au;
|
public const uint GetAndWieldItemOpcode = 0x001Au;
|
||||||
public const uint DropItemOpcode = 0x001Bu;
|
public const uint DropItemOpcode = 0x001Bu;
|
||||||
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
public const uint NoLongerViewingContentsOpcode = 0x0195u;
|
||||||
|
public const uint SetInscriptionOpcode = 0x00BFu;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Merge stack A into stack B of the same item type. Server validates
|
/// 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);
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
|
||||||
return body;
|
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));
|
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
|
/// <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:
|
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
|
||||||
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ public enum PublicWeenieFlags : uint
|
||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
Openable = 0x00000001,
|
Openable = 0x00000001,
|
||||||
|
Inscribable = 0x00000002,
|
||||||
Stuck = 0x00000004,
|
Stuck = 0x00000004,
|
||||||
Player = 0x00000008,
|
Player = 0x00000008,
|
||||||
Attackable = 0x00000010,
|
Attackable = 0x00000010,
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,23 @@ public sealed class AppraisalUiControllerTests
|
||||||
Type = ItemType.MeleeWeapon,
|
Type = ItemType.MeleeWeapon,
|
||||||
Value = 1250,
|
Value = 1250,
|
||||||
Burden = 350,
|
Burden = 350,
|
||||||
|
ContainerId = 0x50000002u,
|
||||||
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
|
||||||
});
|
});
|
||||||
var sent = new List<uint>();
|
var sent = new List<uint>();
|
||||||
|
var inscriptions = new List<(uint ObjectId, string Text)>();
|
||||||
|
var messages = new List<string>();
|
||||||
using var interaction = NewInteraction(objects, sent);
|
using var interaction = NewInteraction(objects, sent);
|
||||||
var combat = new CombatState();
|
var combat = new CombatState();
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
int closed = 0;
|
int closed = 0;
|
||||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
using AppraisalUiController controller = Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
interaction,
|
interaction,
|
||||||
combat,
|
combat,
|
||||||
new Spellbook(),
|
inscriptions,
|
||||||
|
messages,
|
||||||
() => shown++,
|
() => shown++,
|
||||||
() => closed++)!;
|
() => closed++)!;
|
||||||
|
|
||||||
|
|
@ -64,10 +69,12 @@ public sealed class AppraisalUiControllerTests
|
||||||
UiField inscription = Assert.IsType<UiField>(
|
UiField inscription = Assert.IsType<UiField>(
|
||||||
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
layout.FindElement(AppraisalUiController.InscriptionTextId));
|
||||||
Assert.Contains("Remember the fallen", inscription.Text);
|
Assert.Contains("Remember the fallen", inscription.Text);
|
||||||
Assert.False(inscription.Enabled);
|
Assert.True(inscription.Editable);
|
||||||
UiText signature = Assert.IsType<UiText>(
|
UiText signature = Assert.IsType<UiText>(
|
||||||
layout.FindElement(AppraisalUiController.SignatureTextId));
|
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();
|
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
|
||||||
Assert.Equal(1, closed);
|
Assert.Equal(1, closed);
|
||||||
|
|
@ -88,12 +95,13 @@ public sealed class AppraisalUiControllerTests
|
||||||
using var interaction = NewInteraction(objects, sent);
|
using var interaction = NewInteraction(objects, sent);
|
||||||
var combat = new CombatState();
|
var combat = new CombatState();
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
using AppraisalUiController controller = Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
interaction,
|
interaction,
|
||||||
combat,
|
combat,
|
||||||
new Spellbook(),
|
[],
|
||||||
|
[],
|
||||||
() => shown++,
|
() => shown++,
|
||||||
() => { })!;
|
() => { })!;
|
||||||
|
|
||||||
|
|
@ -145,12 +153,13 @@ public sealed class AppraisalUiControllerTests
|
||||||
var objects = new ClientObjectTable();
|
var objects = new ClientObjectTable();
|
||||||
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
|
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
|
||||||
using var interaction = NewInteraction(objects, []);
|
using var interaction = NewInteraction(objects, []);
|
||||||
using AppraisalUiController controller = AppraisalUiController.Bind(
|
using AppraisalUiController controller = Bind(
|
||||||
layout,
|
layout,
|
||||||
objects,
|
objects,
|
||||||
interaction,
|
interaction,
|
||||||
new CombatState(),
|
new CombatState(),
|
||||||
new Spellbook(),
|
[],
|
||||||
|
[],
|
||||||
() => throw new InvalidOperationException("must not show"),
|
() => throw new InvalidOperationException("must not show"),
|
||||||
() => { })!;
|
() => { })!;
|
||||||
|
|
||||||
|
|
@ -158,6 +167,281 @@ public sealed class AppraisalUiControllerTests
|
||||||
Assert.Equal(0u, controller.CurrentObjectId);
|
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(
|
private static ItemInteractionController NewInteraction(
|
||||||
ClientObjectTable objects,
|
ClientObjectTable objects,
|
||||||
List<uint> sent)
|
List<uint> sent)
|
||||||
|
|
|
||||||
|
|
@ -94,4 +94,52 @@ public class UiFieldTests
|
||||||
|
|
||||||
Assert.Equal("5", input.Text);
|
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));
|
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]
|
[Fact]
|
||||||
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,27 @@ namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
public sealed class AppraiseInfoParserTests
|
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>
|
/// <summary>
|
||||||
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
|
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
|
||||||
/// with (guid, flags, success) then per-flag tables.
|
/// with (guid, flags, success) then per-flag tables.
|
||||||
|
|
@ -319,6 +340,27 @@ public sealed class AppraiseInfoParserTests
|
||||||
Assert.Equal(300u, c.Health);
|
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]
|
[Fact]
|
||||||
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
|
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -148,4 +148,42 @@ public sealed class InventoryActionsTests
|
||||||
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8)));
|
||||||
Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12)));
|
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),
|
InventoryActions.BuildGiveObjectRequest(1, 0xAAu, 0xBBu, 3u),
|
||||||
captured);
|
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