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