fix #234: port retail appraisal floaty and inscriptions

This commit is contained in:
Erik 2026-07-23 12:12:57 +02:00
parent 643cdfe66e
commit f1a7912160
23 changed files with 1310 additions and 106 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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` |

View file

@ -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.
---

View file

@ -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

View file

@ -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.

View file

@ -17,13 +17,25 @@ retained examination window.
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
- `ItemExamineUI::HandleInscriptionGainingFocus @ 0x004AC0E0`
- `ItemExamineUI::HandleInscriptionMousePresses @ 0x004AC230`
- `ItemExamineUI::SetInscriptionEditableState @ 0x004AC720`
- `ItemExamineUI::SetInscription @ 0x004AE2F0`
- `ItemExamineUI::HandleInscriptionLosingFocus @ 0x004AE620`
- `gmFloatyExaminationUI::ResizeTo @ 0x004D4470`
- `AppraisalProfile::IsHookedItemInscribable @ 0x005B2F90`
- `HookAppraisalProfile::IsInscribable @ 0x005B64C0`
- `CM_Writing::Event_SetInscription @ 0x006A98B0`
- `PublicWeenieDesc::BF_INSCRIBABLE` in `acclient.h:6434`
- Retail LayoutDesc `0x2100006B`, root `0x100005F2`
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
- ACE:
- `IdentifyResponseFlags`
- `GameEventIdentifyObjectResponse`
- `AppraiseInfo.Write`
- `HookProfile.Write`
- `GameActionSetInscription`
- `Player.HandleActionSetInscription`
- ACViewer's `HookAppraisalProfile` representation, used as a second
interpretation check for the three-field hook profile.
@ -63,6 +75,30 @@ for the current pending GUID releases that one reference.
## IdentifyObjectResponse packet order
The flag values are part of the wire contract and are not the field-order
indices:
```text
IntStatsTable 0x0001
BoolStatsTable 0x0002
FloatStatsTable 0x0004
StringStatsTable 0x0008
SpellBook 0x0010
WeaponProfile 0x0020
HookProfile 0x0040
ArmorProfile 0x0080
CreatureProfile 0x0100
ArmorEnchantmentBitfield 0x0200
ResistEnchantmentBitfield 0x0400
WeaponEnchantmentBitfield 0x0800
DidStatsTable 0x1000
Int64StatsTable 0x2000
ArmorLevels 0x4000
```
ACE writes the gated bodies in the order below, independent of the numerical
position of each flag.
```text
read guid
read flags
@ -178,16 +214,126 @@ same object preserves the user's position.
The LayoutDesc root already contains retail's complete floaty window chrome,
close control, item/creature/character/spell subviews, and scrollbars.
The root's concrete retail type is `gmFloatyExaminationUI`, not a child of
`gmPanelUI`. It is mounted and persisted as its own top-level retained window.
Showing it must not hide, replace, resize, or reposition the shared
Inventory/Skills/Spellbook panel.
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
and persists width/height; it adds no special resize algorithm.
## Slice boundary
## Inscription presentation and eligibility
This slice ports the request lifetime, packet integrity, subview choice,
authored outer layout, basic report content, scrollbar ownership, and combat
refresh. Retail's exact 3D object/creature preview, all specialized
`ItemExamineUI` detail branches, selection-follow preference, and
`SetInscriptionEditableState` plus inscription submission remain explicit
AP-110 work. Until that permission/write transaction exists, the imported
inscription field is display-only rather than accepting edits that cannot be
sent.
```text
SetInscription(profile):
reset inscription field to its unfocused state
clear displayed text, signature, old inscription, scribe name/account
inscribable = object.PublicWeenieDesc has BF_INSCRIBABLE
if object is a hook:
inscribable = profile.HookProfile exists
and (HookProfile.Flags & 1) != 0
if not inscribable:
hide inscription field and signature
return
scribe = profile string property 8, or empty
oldInscription = profile string property 7, or empty
if scribe is empty:
field = "<Inscribe here>"
signature = ""
else:
field = oldInscription
signature = "--" + scribe
if local player is a PSR and scribe-account property 0x17 exists:
signature += " <" + scribeAccount + ">"
show field and signature
SetInscriptionEditableState()
SetInscriptionEditableState:
editable = object exists
and object has BF_INSCRIBABLE
and object is owned by the local player
and (scribe is empty
or case-insensitive scribe == local player name)
PSR may override the ownership/scribe restriction
field.Editable = editable
field.Selectable = editable
inscription background receives mouse presses only when not editable
```
The ordinary-player port does not fabricate PSR status. It applies the exact
ownership/name rule; an eventual PSR player-description capability can add the
retail override without changing this transaction.
For hooks, the appraisal HookProfile is the source of truth for presentation:
`AppraisalProfile::IsHookedItemInscribable` returns false when it is absent and
otherwise returns bit 0. The live public description remains the source used by
`SetInscriptionEditableState`, matching the two retail functions rather than
merging them into one guessed rule.
Noneditable mouse presses report retail's exact reason:
```text
if scribe is nonempty and differs from local player:
"Only <scribe> can change the inscription"
else if object is absent or not owned:
"Item must be in your inventory to inscribe."
else if BF_INSCRIBABLE is absent:
"This item is not inscribable."
```
## Inscription edit transaction
```text
on inscription focus gained:
if scribe is empty:
switch to the normal editing state
clear "<Inscribe here>"
signature = "--" + local player name
on inscription focus lost:
if current object id == 0:
return
text = current field text
skipSend = text is empty and original scribe is empty
if not skipSend and text != oldInscription:
send CM_Writing.Event_SetInscription(object id, text)
if text is empty:
restore unfocused state
field = "<Inscribe here>"
signature = ""
scribe name/account = ""
else:
scribe name = local player name
signature = "--" + local player name
oldInscription = text
```
`CM_Writing::Event_SetInscription` sends a normal UI-counter GameAction:
```text
u32 0xF7B1 GameAction envelope
u32 uiCounter
u32 0x00BF SetInscription
u32 objectGuid
String16L text CP-1252, padded to a four-byte boundary
```
There is no successful server reply in retail captures. The client therefore
updates its field/signature optimistically after sending; ACE deliberately
implements the same behavior.
## Remaining slice boundary
The request lifetime, exact packet flags, independent floaty window,
inscription permission/edit/write transaction, subview choice, authored outer
layout, basic report content, scrollbar ownership, and combat refresh are in
scope. Retail's exact 3D object/creature preview, exhaustive specialized
`ItemExamineUI` detail branches, selection-follow preference, and PSR-only
scribe-account/override behavior remain explicit AP-110 work.

View file

@ -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,

View file

@ -22,6 +22,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint ItemPanelId = 0x1000012Eu;
public const uint ItemTextId = 0x1000013Cu;
public const uint ItemScrollbarId = 0x1000013Du;
public const uint InscriptionBackgroundId = 0x10000137u;
public const uint InscriptionTextId = 0x1000013Eu;
public const uint SignatureTextId = 0x1000013Fu;
public const uint InscriptionScrollbarId = 0x1000046Eu;
@ -38,6 +39,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly ItemInteractionController _interaction;
private readonly CombatState _combat;
private readonly Spellbook _spellbook;
private readonly Func<string> _playerName;
private readonly Action<uint, string> _sendSetInscription;
private readonly Action<string> _systemMessage;
private readonly Action _show;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
@ -47,6 +51,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly UiText? _inscriptionText;
private readonly UiField? _inscriptionField;
private readonly UiText? _signature;
private readonly UiDatElement? _inscriptionBackground;
private readonly UiButton? _close;
private AppraisalView _activeView;
private uint _itemObjectId;
@ -56,6 +61,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
private string _itemReport = string.Empty;
private string _inscriptionValue = string.Empty;
private string _signatureValue = string.Empty;
private string _scribeName = string.Empty;
private string _oldInscription = string.Empty;
private bool _presentationInscribable;
private double _refreshElapsed;
private bool _disposed;
@ -65,6 +73,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Func<string> playerName,
Action<uint, string> sendSetInscription,
Action<string> systemMessage,
Action show,
Action close,
UiElement itemPanel,
@ -77,6 +88,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
_interaction = interaction;
_combat = combat;
_spellbook = spellbook;
_playerName = playerName;
_sendSetInscription = sendSetInscription;
_systemMessage = systemMessage;
_show = show;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
@ -86,6 +100,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
_inscriptionBackground =
layout.FindElement(InscriptionBackgroundId) as UiDatElement;
_close = layout.FindElement(CloseId) as UiButton;
_title.LinesProvider = () =>
@ -99,17 +115,26 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (_inscriptionField is not null)
{
_inscriptionField.SetText(string.Empty);
// Retail conditionally enables this field only after its ownership
// and inscription-permission checks. That write transaction remains
// tracked by AP-110; keep the imported field display-only until the
// matching SetInscriptionEditableState/submit path is present.
_inscriptionField.Enabled = false;
_inscriptionField.ClearOnSubmit = false;
_inscriptionField.RecordHistory = false;
_inscriptionField.Editable = false;
_inscriptionField.Selectable = false;
_inscriptionField.OnFocusGained = HandleInscriptionGainingFocus;
_inscriptionField.OnFocusLost = HandleInscriptionLosingFocus;
_inscriptionField.OnReadOnlyClick = ReportInscriptionUnavailable;
if (layout.FindElement(InscriptionScrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = _inscriptionField.Scroll;
}
if (_signature is not null)
_signature.LinesProvider = () =>
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
if (_close is not null)
_close.OnClick = close;
if (_inscriptionBackground is not null)
{
_inscriptionBackground.OnClick = ReportInscriptionUnavailable;
_inscriptionBackground.ClickThrough = false;
}
SetActiveView(AppraisalView.Item);
}
@ -123,6 +148,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Func<string> playerName,
Action<uint, string> sendSetInscription,
Action<string> systemMessage,
Action show,
Action close)
{
@ -131,6 +159,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(playerName);
ArgumentNullException.ThrowIfNull(sendSetInscription);
ArgumentNullException.ThrowIfNull(systemMessage);
ArgumentNullException.ThrowIfNull(show);
ArgumentNullException.ThrowIfNull(close);
@ -146,6 +177,9 @@ public sealed class AppraisalUiController : IRetainedPanelController
interaction,
combat,
spellbook,
playerName,
sendSetInscription,
systemMessage,
show,
close,
itemPanel,
@ -228,7 +262,19 @@ public sealed class AppraisalUiController : IRetainedPanelController
_itemReport = string.Empty;
_inscriptionValue = string.Empty;
_inscriptionField?.SetText(string.Empty);
if (_inscriptionField is not null)
{
_inscriptionField.Editable = false;
_inscriptionField.Selectable = false;
_inscriptionField.Visible = false;
_inscriptionField.Scroll.SetScrollY(0);
}
_signatureValue = string.Empty;
if (_signature is not null)
_signature.Visible = false;
_scribeName = string.Empty;
_oldInscription = string.Empty;
_presentationInscribable = false;
_itemObjectId = 0;
_creatureObjectId = 0;
_characterObjectId = 0;
@ -246,19 +292,158 @@ public sealed class AppraisalUiController : IRetainedPanelController
obj,
appraisal,
ResolveSpellName);
_inscriptionValue = GetString(appraisal.Properties, 7u);
_inscriptionField?.SetText(_inscriptionValue);
string scribe = GetString(appraisal.Properties, 8u);
_signatureValue = string.IsNullOrWhiteSpace(scribe)
? string.Empty
: $"Inscribed by {scribe}";
SetInscription(obj, appraisal);
if (newlySelected)
{
_itemText.Scroll.SetScrollY(0);
_inscriptionText?.Scroll.SetScrollY(0);
_inscriptionField?.Scroll.SetScrollY(0);
}
}
/// <summary>
/// Port of <c>ItemExamineUI::SetInscription @ 0x004AE2F0</c> and
/// <c>SetInscriptionEditableState @ 0x004AC720</c>.
/// </summary>
private void SetInscription(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal)
{
_oldInscription = string.Empty;
_scribeName = string.Empty;
_inscriptionValue = string.Empty;
_signatureValue = string.Empty;
var publicFlags =
(PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u);
bool publicInscribable =
(publicFlags & PublicWeenieFlags.Inscribable) != 0;
_presentationInscribable = appraisal.HookProfile is { } hook
? (hook.Flags & 0x1u) != 0
: publicInscribable;
if (!_presentationInscribable)
{
if (_inscriptionField is not null)
{
_inscriptionField.SetText(string.Empty);
_inscriptionField.Editable = false;
_inscriptionField.Selectable = false;
_inscriptionField.Visible = false;
}
if (_inscriptionText is not null)
_inscriptionText.Visible = false;
if (_signature is not null)
_signature.Visible = false;
if (_inscriptionBackground is not null)
_inscriptionBackground.ClickThrough = false;
return;
}
_scribeName = GetString(appraisal.Properties, 8u);
_oldInscription = GetString(appraisal.Properties, 7u);
if (string.IsNullOrEmpty(_scribeName))
{
_inscriptionValue = "<Inscribe here>";
}
else if (!string.IsNullOrEmpty(_oldInscription))
{
_inscriptionValue = _oldInscription;
_signatureValue = $"--{_scribeName}";
}
if (_inscriptionField is not null)
{
_inscriptionField.SetText(_inscriptionValue);
_inscriptionField.Visible = true;
}
if (_inscriptionText is not null)
_inscriptionText.Visible = true;
if (_signature is not null)
_signature.Visible = true;
bool editable = publicInscribable
&& _interaction.IsOwnedByPlayer(obj.ObjectId)
&& (string.IsNullOrEmpty(_scribeName)
|| string.Equals(
_scribeName,
_playerName(),
StringComparison.OrdinalIgnoreCase));
if (_inscriptionField is not null)
{
_inscriptionField.Editable = editable;
_inscriptionField.Selectable = editable;
}
if (_inscriptionBackground is not null)
_inscriptionBackground.ClickThrough = editable;
}
private void HandleInscriptionGainingFocus()
{
if (_inscriptionField is not { Editable: true } field)
return;
if (string.IsNullOrEmpty(_scribeName))
{
field.SetText(string.Empty);
_inscriptionValue = string.Empty;
}
_signatureValue = $"--{_playerName()}";
}
private void HandleInscriptionLosingFocus(string text)
{
if (_itemObjectId == 0 || !_presentationInscribable)
return;
bool skipEmptyUnscribed = string.IsNullOrEmpty(text)
&& string.IsNullOrEmpty(_scribeName);
if (!skipEmptyUnscribed
&& !string.Equals(
text,
_oldInscription,
StringComparison.Ordinal))
{
_sendSetInscription(_itemObjectId, text);
}
if (string.IsNullOrEmpty(text))
{
_inscriptionValue = "<Inscribe here>";
_signatureValue = string.Empty;
_scribeName = string.Empty;
_inscriptionField?.SetText(_inscriptionValue);
}
else
{
_inscriptionValue = text;
_scribeName = _playerName();
_signatureValue = $"--{_scribeName}";
}
_oldInscription = text;
}
private void ReportInscriptionUnavailable()
{
if (_inscriptionField?.Editable == true)
return;
if (!string.IsNullOrEmpty(_scribeName)
&& !string.Equals(
_scribeName,
_playerName(),
StringComparison.OrdinalIgnoreCase))
{
_systemMessage($"Only {_scribeName} can change the inscription");
return;
}
if (_itemObjectId == 0
|| !_interaction.IsOwnedByPlayer(_itemObjectId))
{
_systemMessage("Item must be in your inventory to inscribe.");
return;
}
_systemMessage("This item is not inscribable.");
}
private void ApplyCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
@ -411,10 +596,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
_disposed = true;
if (_close is not null)
_close.OnClick = null;
if (_inscriptionField is not null)
{
_inscriptionField.OnFocusGained = null;
_inscriptionField.OnFocusLost = null;
_inscriptionField.OnReadOnlyClick = null;
}
if (_inscriptionBackground is not null)
_inscriptionBackground.OnClick = null;
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll)))
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll))
|| (_inscriptionField is not null
&& ReferenceEquals(scrollbar.Model, _inscriptionField.Scroll)))
scrollbar.Model = null;
}
}

View file

@ -16,12 +16,6 @@ public static class RetailPanelCatalog
public const uint Character = 11u;
public const uint Magic = 13u;
public const uint Vitae = 15u;
/// <summary>
/// Private retained-host identity for retail's floating examination UI.
/// The original opens it by notice rather than a gmPanelUI numeric child,
/// so this value is deliberately outside the DAT panel-id range.
/// </summary>
public const uint Examination = 0x8000_0001u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
@ -34,7 +28,6 @@ public static class RetailPanelCatalog
(Character, WindowNames.Character),
(Magic, WindowNames.Spellbook),
(Vitae, WindowNames.Vitae),
(Examination, WindowNames.Examination),
};
private static readonly (uint PanelId, string WindowName)[] Toolbar =

View file

@ -159,6 +159,11 @@ public sealed record RetailUiCursorBindings(
public sealed record ConfirmationRuntimeBindings(
Action<uint, uint, bool> SendResponse);
public sealed record AppraisalRuntimeBindings(
Func<string> PlayerName,
Action<uint, string> SendSetInscription,
Action<string> DisplaySystemMessage);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
@ -177,6 +182,7 @@ public sealed record RetailUiRuntimeBindings(
ExternalContainerRuntimeBindings ExternalContainer,
RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
AppraisalRuntimeBindings Appraisal,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@ -431,7 +437,7 @@ public sealed class RetailUiRuntime : IDisposable
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
Host.HideWindow(WindowNames.Examination);
}
public void UpdateCursor(IEnumerable<IMouse> mice)
@ -971,9 +977,10 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Inventory.ItemInteraction,
_bindings.Combat.State,
_bindings.Magic.Spellbook,
show: () => _panelUi.SetPanelVisibility(
RetailPanelCatalog.Examination,
visible: true),
_bindings.Appraisal.PlayerName,
_bindings.Appraisal.SendSetInscription,
_bindings.Appraisal.DisplaySystemMessage,
show: () => Host.ShowWindow(WindowNames.Examination),
close: () => CloseWindow(WindowNames.Examination));
if (controller is null)
{
@ -984,7 +991,7 @@ public sealed class RetailUiRuntime : IDisposable
AppraisalController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
_ = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
@ -1006,10 +1013,6 @@ public sealed class RetailUiRuntime : IDisposable
ContentClickThrough = false,
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Examination,
WindowNames.Examination,
handle);
Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B.");
}

View file

@ -5,7 +5,7 @@ using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Generic editable one-line field widget. Port of retail <c>UIElement_Field</c>
/// Generic editable field widget. Port of retail <c>UIElement_Field</c>
/// (<c>RegisterElementClass(3)</c> @ acclient_2013_pseudo_c.txt:126190). Carries
/// retail <c>Field</c>'s drag-drop hooks (<c>CatchDroppedItem</c>/<c>MouseOverTop</c>)
/// as stubs for future item-window use.
@ -22,6 +22,8 @@ namespace AcDream.App.UI;
/// </summary>
public sealed class UiField : UiElement
{
private readonly record struct WrappedLine(int Start, int Length, string Text);
public uint ElementId { get; set; }
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
@ -39,6 +41,31 @@ public sealed class UiField : UiElement
public bool RecordHistory { get; set; } = true;
public Func<char, bool>? CharacterFilter { get; set; }
public bool SelectAllOnFocus { get; set; }
public UiScrollable Scroll { get; } = new();
public Action? OnReadOnlyClick { get; set; }
private bool _editable = true;
/// <summary>
/// Retail property 0x16. Read-only fields remain mouse-visible so their
/// authored background can report why editing is unavailable, but they do
/// not take keyboard focus or accept mutations.
/// </summary>
public bool Editable
{
get => _editable;
set
{
if (_editable == value) return;
_editable = value;
AcceptsFocus = value;
IsEditControl = value;
if (!value && _focused)
FindRoot()?.SetKeyboardFocus(null);
if (!value)
_repeatKey = null;
}
}
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
@ -72,6 +99,9 @@ public sealed class UiField : UiElement
private bool _selecting; // mouse drag in progress
private bool _preserveFocusSelectionOnMouseDown;
private float _scrollX; // horizontal pixel scroll so the caret stays in the field
private IReadOnlyList<WrappedLine> _wrappedLines = Array.Empty<WrappedLine>();
private float _wrappedLineHeight = 14f;
private bool _suppressNextNewlineChar;
// Held-key auto-repeat (Silk delivers one KeyDown per physical press).
private Silk.NET.Input.Key? _repeatKey;
@ -94,7 +124,12 @@ public sealed class UiField : UiElement
public void InsertChar(char c)
{
if (c < 0x20 || c == 0x7F || (CharacterFilter is not null && !CharacterFilter(c))) return;
if (!Editable) return;
if (!OneLine && (c == '\r' || c == '\n'))
c = '\n';
else if (c < 0x20 || c == 0x7F)
return;
if (CharacterFilter is not null && !CharacterFilter(c)) return;
DeleteSelection();
if (_text.Length >= MaxCharacters) return;
_text = _text.Insert(_caret, c.ToString());
@ -104,6 +139,7 @@ public sealed class UiField : UiElement
public void Backspace()
{
if (!Editable) return;
if (DeleteSelection()) return;
if (_caret == 0) return;
_text = _text.Remove(_caret - 1, 1);
@ -112,6 +148,7 @@ public sealed class UiField : UiElement
public void DeleteForward()
{
if (!Editable) return;
if (DeleteSelection()) return;
if (_caret >= _text.Length) return;
_text = _text.Remove(_caret, 1);
@ -184,6 +221,7 @@ public sealed class UiField : UiElement
private void CutSelection()
{
if (!Editable) return;
if (!HasSelection) return;
CopySelection();
DeleteSelection();
@ -192,15 +230,33 @@ public sealed class UiField : UiElement
private void Paste()
{
if (!Editable) return;
if (Keyboard is null) return;
string clip = Keyboard.ClipboardText ?? "";
if (clip.Length == 0) return;
// Single-line field: strip control chars (newlines/tabs) from pasted text.
// Retail one-line fields strip controls. Multi-line fields preserve CR/LF
// as one normalized newline and still reject other controls.
var sb = new System.Text.StringBuilder(clip.Length);
bool previousCr = false;
foreach (char ch in clip)
{
if (!OneLine && (ch == '\r' || ch == '\n'))
{
if (ch == '\n' && previousCr)
{
previousCr = false;
continue;
}
sb.Append('\n');
previousCr = ch == '\r';
continue;
}
previousCr = false;
if (ch >= 0x20 && ch != 0x7F
&& (CharacterFilter is null || CharacterFilter(ch))) sb.Append(ch);
&& (CharacterFilter is null || CharacterFilter(ch)))
sb.Append(ch);
}
if (sb.Length == 0) return;
DeleteSelection();
@ -216,6 +272,7 @@ public sealed class UiField : UiElement
public void Submit()
{
if (!Editable) return;
var t = _text;
if (t.Trim().Length == 0)
{
@ -314,6 +371,12 @@ public sealed class UiField : UiElement
}
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
if (!OneLine)
{
DrawMultiLine(ctx);
return;
}
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float ty = (Height - lh) * 0.5f;
float visibleW = MathF.Max(1f, Width - 2f * Padding);
@ -370,11 +433,198 @@ public sealed class UiField : UiElement
return 0f;
}
private void DrawMultiLine(UiRenderContext ctx)
{
float lineHeight = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float visibleWidth = MathF.Max(1f, Width - (2f * Padding));
float visibleHeight = MathF.Max(1f, Height - (2f * Padding));
IReadOnlyList<WrappedLine> lines = BuildWrappedLines(visibleWidth);
_wrappedLines = lines;
_wrappedLineHeight = lineHeight;
Scroll.LineHeight = Math.Max(1, (int)MathF.Round(lineHeight));
Scroll.SetExtents(
Math.Max(1, (int)MathF.Ceiling(lines.Count * lineHeight)),
Math.Max(1, (int)MathF.Floor(visibleHeight)),
preserveEnd: false);
int caretLine = FindCaretLine(lines, _caret);
if (_focused)
{
float caretTop = caretLine * lineHeight;
float caretBottom = caretTop + lineHeight;
if (caretTop < Scroll.ScrollY)
Scroll.SetScrollY((int)MathF.Floor(caretTop));
else if (caretBottom > Scroll.ScrollY + visibleHeight)
Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight));
}
ctx.PushClip(0f, 0f, Width, Height);
try
{
var (selectionLow, selectionHigh) = SelSpan();
for (int i = 0; i < lines.Count; i++)
{
WrappedLine line = lines[i];
float y = Padding + (i * lineHeight) - Scroll.ScrollY;
if (y + lineHeight <= Padding || y >= Height - Padding)
continue;
int lineEnd = line.Start + line.Length;
int highlightLow = Math.Max(selectionLow, line.Start);
int highlightHigh = Math.Min(selectionHigh, lineEnd);
if (HasSelection && highlightHigh > highlightLow)
{
float x0 = Padding + MeasureRange(
line.Start,
highlightLow - line.Start);
float x1 = Padding + MeasureRange(
line.Start,
highlightHigh - line.Start);
ctx.DrawFill(
x0,
y,
MathF.Max(0f, x1 - x0),
lineHeight,
SelectionColor);
}
if (DatFont is { } dat)
ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor);
else if (Font is { } bitmap)
ctx.DrawString(line.Text, Padding, y, TextColor, bitmap);
}
if (_focused && lines.Count > 0)
{
WrappedLine line = lines[caretLine];
int lineColumn = Math.Clamp(
_caret - line.Start,
0,
line.Length);
float x = Padding + MeasureRange(line.Start, lineColumn);
float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY;
ctx.DrawFill(x, y, 1f, lineHeight, TextColor);
}
}
finally
{
ctx.PopClip();
}
}
private IReadOnlyList<WrappedLine> BuildWrappedLines(float maximumWidth)
{
var lines = new List<WrappedLine>();
if (_text.Length == 0)
{
lines.Add(new WrappedLine(0, 0, string.Empty));
return lines;
}
int start = 0;
while (start < _text.Length)
{
if (_text[start] == '\n')
{
lines.Add(new WrappedLine(start, 0, string.Empty));
start++;
continue;
}
int paragraphEnd = _text.IndexOf('\n', start);
if (paragraphEnd < 0)
paragraphEnd = _text.Length;
int end = start;
int lastWhitespaceEnd = -1;
while (end < paragraphEnd)
{
int candidateEnd = end + 1;
if (MeasureRange(start, candidateEnd - start) > maximumWidth
&& end > start)
break;
end = candidateEnd;
if (char.IsWhiteSpace(_text[end - 1]))
lastWhitespaceEnd = end;
}
if (end < paragraphEnd && lastWhitespaceEnd > start)
end = lastWhitespaceEnd;
if (end == start)
end++;
int length = end - start;
lines.Add(new WrappedLine(
start,
length,
_text.Substring(start, length)));
start = end;
if (start == paragraphEnd && start < _text.Length)
start++;
}
if (_text.EndsWith('\n'))
lines.Add(new WrappedLine(_text.Length, 0, string.Empty));
return lines;
}
private int FindCaretLine(IReadOnlyList<WrappedLine> lines, int caret)
{
for (int i = 0; i < lines.Count; i++)
{
WrappedLine line = lines[i];
int end = line.Start + line.Length;
if (caret < end || caret == end && i == lines.Count - 1)
return i;
if (caret == end && i + 1 < lines.Count
&& lines[i + 1].Start > caret)
return i;
}
return Math.Max(0, lines.Count - 1);
}
private float MeasureRange(int start, int length)
{
if (length <= 0) return 0f;
string value = _text.Substring(start, length);
return DatFont?.MeasureWidth(value)
?? Font?.MeasureWidth(value)
?? value.Length * 8f;
}
private int HitChar(float localX, float localY)
{
if (OneLine)
return HitCharX(localX);
if (_wrappedLines.Count == 0)
return _text.Length;
int lineIndex = Math.Clamp(
(int)MathF.Floor(
(localY - Padding + Scroll.ScrollY)
/ MathF.Max(1f, _wrappedLineHeight)),
0,
_wrappedLines.Count - 1);
WrappedLine line = _wrappedLines[lineIndex];
float target = MathF.Max(0f, localX - Padding);
int best = 0;
float bestDistance = float.MaxValue;
for (int col = 0; col <= line.Length; col++)
{
float distance = MathF.Abs(
MeasureRange(line.Start, col) - target);
if (distance >= bestDistance) continue;
bestDistance = distance;
best = col;
}
return line.Start + best;
}
// ── Auto-repeat ──────────────────────────────────────────────────────
protected override void OnTick(double deltaSeconds)
{
if (_repeatKey is not { } k) return;
if (!Editable || _repeatKey is not { } k) return;
_repeatTimer -= deltaSeconds;
if (_repeatTimer > 0) return;
_repeatTimer = RepeatRate;
@ -425,8 +675,18 @@ public sealed class UiField : UiElement
return true;
case UiEventType.Char:
InsertChar((char)e.Data0);
{
char value = (char)e.Data0;
if (_suppressNextNewlineChar
&& (value == '\r' || value == '\n'))
{
_suppressNextNewlineChar = false;
return true;
}
_suppressNextNewlineChar = false;
InsertChar(value);
return true;
}
case UiEventType.MouseDown:
if (_preserveFocusSelectionOnMouseDown)
@ -435,12 +695,13 @@ public sealed class UiField : UiElement
_selecting = false;
return true;
}
_caret = HitCharX(e.Data1);
_caret = HitChar(e.Data1, e.Data2);
_selAnchor = Selectable ? _caret : null;
_selecting = Selectable;
return true;
case UiEventType.MouseMove:
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
if (Selectable && _selecting)
_caret = HitChar(e.Data1, e.Data2);
return true;
case UiEventType.MouseUp:
_selecting = false;
@ -452,6 +713,8 @@ public sealed class UiField : UiElement
case UiEventType.KeyDown:
{
if (!Editable)
return true;
var key = (Silk.NET.Input.Key)e.Data0;
if (CtrlHeld())
{
@ -470,6 +733,12 @@ public sealed class UiField : UiElement
{
case Silk.NET.Input.Key.Enter:
case Silk.NET.Input.Key.KeypadEnter:
if (!OneLine)
{
InsertChar('\n');
_suppressNextNewlineChar = true;
return true;
}
Submit();
FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending
return true;
@ -484,6 +753,20 @@ public sealed class UiField : UiElement
}
return false;
}
case UiEventType.Scroll:
if (!OneLine)
{
Scroll.ScrollByLines(-Math.Sign(e.Data0));
return true;
}
return false;
case UiEventType.Click:
if (!Editable)
{
OnReadOnlyClick?.Invoke();
return true;
}
return false;
}
return false;
}

View file

@ -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,
}

View file

@ -26,6 +26,7 @@ public static class InventoryActions
public const uint GetAndWieldItemOpcode = 0x001Au;
public const uint DropItemOpcode = 0x001Bu;
public const uint NoLongerViewingContentsOpcode = 0x0195u;
public const uint SetInscriptionOpcode = 0x00BFu;
/// <summary>
/// Merge stack A into stack B of the same item type. Server validates
@ -172,4 +173,36 @@ public static class InventoryActions
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid);
return body;
}
/// <summary>
/// Set or clear an item's inscription. Retail
/// <c>CM_Writing::Event_SetInscription @ 0x006A98B0</c> writes the object
/// GUID followed by one CP-1252 String16L and receives no success response.
/// </summary>
public static byte[] BuildSetInscription(
uint seq,
uint itemGuid,
string inscription)
{
ArgumentNullException.ThrowIfNull(inscription);
byte[] text = Encodings.Windows1252.GetBytes(inscription);
if (text.Length > ushort.MaxValue)
throw new ArgumentException(
"Inscription is too long for String16L.",
nameof(inscription));
int stringRecordLength = (2 + text.Length + 3) & ~3;
byte[] body = new byte[16 + stringRecordLength];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(
body.AsSpan(8),
SetInscriptionOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
BinaryPrimitives.WriteUInt16LittleEndian(
body.AsSpan(16),
(ushort)text.Length);
text.CopyTo(body, 18);
return body;
}
}

View file

@ -1788,6 +1788,20 @@ public sealed class WorldSession : IDisposable
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
}
/// <summary>
/// Send retail SetInscription (0x00BF). The retail client updates the
/// examination field optimistically because the server sends no success
/// response.
/// </summary>
public void SendSetInscription(uint itemGuid, string inscription)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildSetInscription(
seq,
itemGuid,
inscription));
}
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>

View file

@ -14,6 +14,7 @@ public enum PublicWeenieFlags : uint
{
None = 0,
Openable = 0x00000001,
Inscribable = 0x00000002,
Stuck = 0x00000004,
Player = 0x00000008,
Attackable = 0x00000010,

View file

@ -23,18 +23,23 @@ public sealed class AppraisalUiControllerTests
Type = ItemType.MeleeWeapon,
Value = 1250,
Burden = 350,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var sent = new List<uint>();
var inscriptions = new List<(uint ObjectId, string Text)>();
var messages = new List<string>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
int closed = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
inscriptions,
messages,
() => shown++,
() => closed++)!;
@ -64,10 +69,12 @@ public sealed class AppraisalUiControllerTests
UiField inscription = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.Contains("Remember the fallen", inscription.Text);
Assert.False(inscription.Enabled);
Assert.True(inscription.Editable);
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.Equal("Inscribed by Tester", Assert.Single(signature.LinesProvider()).Text);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
Assert.Empty(inscriptions);
Assert.Empty(messages);
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
Assert.Equal(1, closed);
@ -88,12 +95,13 @@ public sealed class AppraisalUiControllerTests
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
[],
[],
() => shown++,
() => { })!;
@ -145,12 +153,13 @@ public sealed class AppraisalUiControllerTests
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = AppraisalUiController.Bind(
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
new Spellbook(),
[],
[],
() => throw new InvalidOperationException("must not show"),
() => { })!;
@ -158,6 +167,281 @@ public sealed class AppraisalUiControllerTests
Assert.Equal(0u, controller.CurrentObjectId);
}
[Fact]
public void EmptyOwnedInscription_FocusEditAndBlur_SendsRetailTransactionOnce()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
Type = ItemType.MeleeWeapon,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var inscriptions = new List<(uint ObjectId, string Text)>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
inscriptions,
[],
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.True(field.Editable);
Assert.Equal("<Inscribe here>", field.Text);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
Assert.Equal(string.Empty, field.Text);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
foreach (char c in "For glory")
field.InsertChar(c);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Equal(
new[] { (ObjectId, "For glory") },
inscriptions);
Assert.Equal("--Tester", Assert.Single(signature.LinesProvider()).Text);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusGained));
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Single(inscriptions);
}
[Fact]
public void ExistingInscription_ClearOnBlur_SendsEmptyAndRestoresPlaceholder()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
Type = ItemType.MeleeWeapon,
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var inscriptions = new List<(uint ObjectId, string Text)>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
inscriptions,
[],
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Strings[7u] = "Old words";
properties.Strings[8u] = "Tester";
Assert.True(controller.Apply(Parsed(properties)));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
field.SetText(string.Empty);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.FocusLost));
Assert.Equal(new[] { (ObjectId, string.Empty) }, inscriptions);
Assert.Equal("<Inscribe here>", field.Text);
Assert.Equal(string.Empty, Assert.Single(signature.LinesProvider()).Text);
}
[Fact]
public void InscriptionPermissionMessages_MatchRetail()
{
ImportedLayout otherScribeLayout = FixtureLoader.LoadExamination();
var otherScribeObjects = new ClientObjectTable();
otherScribeObjects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
ContainerId = 0x50000002u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
var messages = new List<string>();
using var otherInteraction = NewInteraction(otherScribeObjects, []);
using AppraisalUiController otherController = Bind(
otherScribeLayout,
otherScribeObjects,
otherInteraction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
otherInteraction.ExamineSelectedOrEnterMode(ObjectId);
var otherProperties = new PropertyBundle();
otherProperties.Strings[7u] = "Hands off";
otherProperties.Strings[8u] = "Other";
Assert.True(otherController.Apply(Parsed(otherProperties)));
UiField otherField = Assert.IsType<UiField>(
otherScribeLayout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.False(otherField.Editable);
otherField.OnEvent(new UiEvent(
otherField.EventId,
otherField,
UiEventType.Click));
Assert.Equal("Only Other can change the inscription", Assert.Single(messages));
ImportedLayout unownedLayout = FixtureLoader.LoadExamination();
var unownedObjects = new ClientObjectTable();
unownedObjects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Sword",
ContainerId = 0x60000000u,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Inscribable,
});
messages.Clear();
using var unownedInteraction = NewInteraction(unownedObjects, []);
using AppraisalUiController unownedController = Bind(
unownedLayout,
unownedObjects,
unownedInteraction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
unownedInteraction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(unownedController.Apply(Parsed(new PropertyBundle())));
UiField unownedField = Assert.IsType<UiField>(
unownedLayout.FindElement(AppraisalUiController.InscriptionTextId));
unownedField.OnEvent(new UiEvent(
unownedField.EventId,
unownedField,
UiEventType.Click));
Assert.Equal(
"Item must be in your inventory to inscribe.",
Assert.Single(messages));
}
[Fact]
public void NonInscribableItem_HidesEditorAndBackgroundReportsRetailMessage()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Component",
ContainerId = 0x50000002u,
});
var messages = new List<string>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
Assert.True(controller.Apply(Parsed(new PropertyBundle())));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.False(field.Visible);
Assert.False(signature.Visible);
UiDatElement background = Assert.IsType<UiDatElement>(
layout.FindElement(AppraisalUiController.InscriptionBackgroundId));
background.OnClick!.Invoke();
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
}
[Fact]
public void HookProfileControlsVisibilityButPublicFlagStillControlsEditing()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Hooked Decoration",
ContainerId = 0x50000002u,
});
var messages = new List<string>();
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = Bind(
layout,
objects,
interaction,
new CombatState(),
[],
messages,
() => { },
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
AppraiseInfoParser.Parsed appraisal = Parsed(new PropertyBundle()) with
{
Flags = AppraiseInfoParser.IdentifyResponseFlags.HookProfile,
HookProfile = new AppraiseInfoParser.HookProfile(
Flags: 1u,
ValidLocations: 0u,
AmmoType: 0u),
};
Assert.True(controller.Apply(appraisal));
UiField field = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.True(field.Visible);
Assert.False(field.Editable);
field.OnEvent(new UiEvent(field.EventId, field, UiEventType.Click));
Assert.Equal("This item is not inscribable.", Assert.Single(messages));
}
[Fact]
public void Examination_IsAnIndependentFloatyWindow_NotSharedMainPanelContent()
{
Assert.False(
RetailPanelCatalog.TryGetPanelId(WindowNames.Examination, out _));
Assert.DoesNotContain(
RetailPanelCatalog.MountedPanels,
mounted => mounted.WindowName == WindowNames.Examination);
}
private static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
List<(uint ObjectId, string Text)> inscriptions,
List<string> messages,
Action show,
Action close)
=> AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => "Tester",
(objectId, text) => inscriptions.Add((objectId, text)),
messages.Add,
show,
close);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
List<uint> sent)

View file

@ -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);
}
}

View file

@ -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()

View file

@ -8,6 +8,27 @@ namespace AcDream.Core.Net.Tests.Messages;
public sealed class AppraiseInfoParserTests
{
[Theory]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable, 0x0001u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.BoolStatsTable, 0x0002u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.FloatStatsTable, 0x0004u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.StringStatsTable, 0x0008u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.SpellBook, 0x0010u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponProfile, 0x0020u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.HookProfile, 0x0040u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorProfile, 0x0080u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, 0x0100u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield, 0x0200u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ResistEnchantmentBitfield, 0x0400u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.WeaponEnchantmentBitfield, 0x0800u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.DidStatsTable, 0x1000u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.Int64StatsTable, 0x2000u)]
[InlineData(AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels, 0x4000u)]
public void IdentifyResponseFlags_MatchRetailWireValues(
AppraiseInfoParser.IdentifyResponseFlags flag,
uint wireValue)
=> Assert.Equal(wireValue, (uint)flag);
/// <summary>
/// Build an AppraiseInfo payload matching ACE's wire format. Starts
/// with (guid, flags, success) then per-flag tables.
@ -319,6 +340,27 @@ public sealed class AppraiseInfoParserTests
Assert.Equal(300u, c.Health);
}
[Fact]
public void TryParse_LiteralAceCreatureFlag_DoesNotDecodeAsWeapon()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write(0x0100u); // ACE IdentifyResponseFlags.CreatureProfile
bw.Write(1u);
bw.Write(0u);
bw.Write(300u);
bw.Write(400u);
AppraiseInfoParser.Parsed? parsed =
AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed);
Assert.NotNull(parsed.Value.CreatureProfile);
Assert.Null(parsed.Value.WeaponProfile);
Assert.Equal(300u, parsed.Value.CreatureProfile.Value.Health);
}
[Fact]
public void TryParse_ArmorEnchantmentBitfield_HighlightAndColor()
{

View file

@ -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());
}
}

View file

@ -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);
}
}