feat(ui): port retail appraisal panel

Preserve retail's one-pending-appraisal busy lifetime, parse the complete gated response, and mount the authored examination layout in the shared main-panel host. Keep known 3D preview and inscription-write gaps explicit in AP-110.
This commit is contained in:
Erik 2026-07-23 11:34:08 +02:00
parent 6b1ae4fb76
commit 643cdfe66e
24 changed files with 17132 additions and 40 deletions

View file

@ -108,22 +108,23 @@ movement queries.
## Current state
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world ACTIVE.** Retail
casting, spellbook/component-book UI, active effects, recall/portal-space
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips.
Release builds; the current interaction-completion baseline passes 3,488 App
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate.
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's retail examination request/panel implementation is
code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

View file

@ -106,22 +106,23 @@ movement queries.
## Current state
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world NEXT.** Retail
**M3 — Cast a spell LANDED 2026-07-21. M4 — Live in the world ACTIVE.** Retail
casting, spellbook/component-book UI, active effects, recall/portal-space
presentation, the R6 locomotion/collision/projectile/teleport/radar rebaseline,
and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
Release builds; the structural closeout plus interaction-completion Slice 1
pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution
tests / 5 skips.
Release builds; the current interaction-completion baseline passes 3,488 App
tests / 3 skips and 7,885 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
spell-bar overflow, status Use/Assess, assessment information,
equipped-child picking, vendor browsing, and authoritative vendor
transactions. Slice 1 is code-complete and awaits its connected visual gate.
transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's retail examination request/panel implementation is
code-complete and awaits its connected visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated

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/examine/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat plus M3 spellbook/component/effects/favorite-spell/Link/Vitae surfaces now cover the active melee/missile/magic loops; the Link page has exact ping RTT but reports the current transport's zero default until its missing reliability statistics land | Large portions of retail gameplay still have no production UI; real packet loss is displayed as 0.00% instead of retail's moving average | Named `gm*UI::PostInit` methods; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination now has its authored outer layout and basic item/creature/character reports, but still lacks the exact 3D preview and exhaustive specialized detail regions, conditional inscription-write transaction, and selection-follow preference. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation loop cover the active loops; the remaining examination mechanisms require additional render and item-write owners rather than fabricated content | Specialized assessments omit some retail fields/3D preview and inscriptions are display-only; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |

View file

@ -29,8 +29,13 @@ That correction is user-confirmed. The requested adjacent cooldown follow-up
ports the optional public shared-cooldown metadata, the authoritative
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
presentation. Its visual gate comes next; the magnifying-glass Assess command
then follows in the same slice.
presentation; its connected gate passed. The existing magnifying-glass Assess
command was already retail-faithful. Slice 3 is now code-complete: the complete
appraisal response reaches a balanced pending/current request owner and the
imported 310 x 400 examination layout through the canonical shared main-panel
host. Item, creature, and character views, authored scrollbars, first-response
visibility, and visible-combat refresh are pinned; the connected examination
gate is next.
---

View file

@ -96,8 +96,12 @@ The follow-up retail correction for zero-valued direct-use items, named
AutoWear blockers, and carried-use delivery is user-confirmed. The requested
adjacent cooldown follow-up now preserves the item's shared group/duration,
projects the authoritative cooldown-enchantment bucket, and draws retail's ten
DAT-authored radial steps across all item-slot surfaces. Its visual gate is
next; the Assess half then continues.
DAT-authored radial steps across all item-slot surfaces; its connected gate
passed. The pre-existing Assess command has been reconciled against retail and
Slice 3 is code-complete: strict full appraisal parsing, balanced
pending/current request ownership, and LayoutDesc `0x2100006B` item,
creature, and character views now live in the canonical shared main-panel
host. Its connected examination gate is next.
Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -1,9 +1,10 @@
# World interaction completion — pre-M4 program
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
zero-useability, carried direct-use, and AutoWear correction passed their
connected gates. The requested shared item-cooldown follow-up is code-complete
and awaits its visual gate before Assess continues.
zero-useability, carried direct-use, AutoWear correction, and the requested
shared item-cooldown follow-up passed their connected gates. The existing
retail Assess command has been reconciled and Slice 3's examination panel is
code-complete; its connected visual gate is next.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell.
@ -106,6 +107,26 @@ The focused parser/Core/UI/production-DAT tests, warning-free Release solution
build, 3,482 App tests / 3 skips, and 7,875 complete-solution tests / 5 skips
pass. AP-123 records only the retained toolkit's procedural-child adaptation;
the visible assets, ordering, timing, and shared-group behavior are exact.
The user accepted the live cooldown presentation 2026-07-23.
Slice 3 preserves the existing toolbar/keyboard Assess command and ports the
retail response owner around it. One shared UI-busy reference covers the
latest pending GUID, stale replies are rejected, the accepted reply becomes
the current appraisal, and combat-time creature/player refreshes occur every
0.75 seconds only while the examination window is visible. Complete
`IdentifyObjectResponse` parsing now includes the three-word HookProfile and
rejects truncated gated payloads rather than continuing from a corrupt cursor.
LayoutDesc `0x2100006B`, root `0x100005F2`, supplies the complete 310 x 400
floaty chrome, title, item/creature/character subviews, close control, and
authored scrollbars. The panel participates in the canonical one-active
main-panel geometry/lifecycle rather than owning a parallel location. Basic
item, creature, and player reports are live; exact 3D preview/specialized
detail regions, conditional inscription editing, and selection-follow remain
the narrowed AP-110 residual. Research:
[`../research/2026-07-23-retail-appraisal-ui-pseudocode.md`](../research/2026-07-23-retail-appraisal-ui-pseudocode.md).
The focused parser/router/request/controller/fixture tests pass, as do the
Release solution build with 17 pre-existing tracked test warnings, 3,488 App
tests / 3 skips, and 7,885 complete-solution tests / 5 skips.
## Slice 1 — spell-bar overflow arrows
@ -183,11 +204,40 @@ Named retail references and executable pseudocode are recorded in
request owner, and targeted items enter `UseItemOnTarget`, which already
owns the retail target cursor.
### Remaining Slice 2 work
### Slice 2 closeout
- Run the connected visual gate for the shared item cooldown: use Blackmoor's
Favor or a summon/stone and verify its inventory and shortcut aliases count
down together, then clear.
- Port the magnifying-glass Assess command state and activation.
- Continue directly into the Assess information slice after the combined
status-control gate.
- The shared item-cooldown visual gate passed.
- `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` confirms the existing
magnifying-glass path was already correct: assess the selected GUID
immediately, otherwise enter one-shot Examine target mode.
- Slice 3 owns the response lifetime and retained examination presentation;
Slice 2 adds no duplicate command path.
## Slice 3 — assessment information panel
### Implemented ownership
1. `AppraiseInfoParser` owns the complete positional response payload,
including HookProfile and strict truncation failure.
2. `ItemInteractionController` owns the pending/current GUID pair and the
balanced shared busy reference.
3. `AppraisalUiController` owns response acceptance, item/creature/character
subview selection, report projection, scroll preservation, first-response
visibility, and visible-combat refresh.
4. `RetailUiRuntime` imports the retail layout and registers it with
`RetailPanelUiController`, so Inventory, Skills, Spellbook, effects, and
Examination remain views of the same movable/resizable main panel.
5. Network workers deliver immutable parsed data through the existing
session router; retained state changes remain on the update thread.
### Connected gate
- Select an item and click the magnifying glass (or press Assess): the shared
panel should show its name, available properties, descriptions,
inscription/signature, retail chrome, and working scrollbar.
- Assess a monster, NPC, and player: the correct creature/character page
should appear and the busy cursor should clear.
- In combat, leave a creature assessment open long enough to observe a health
refresh; closing it or opening another shared panel must stop refreshes.
- Move/resize Examination, switch to Inventory or Skills, and confirm the
shared rectangle is retained.

View file

@ -0,0 +1,193 @@
# Retail appraisal request and examination UI pseudocode
**Date:** 2026-07-23
**Scope:** status-bar Assess request, IdentifyObjectResponse parsing, and the
retained examination window.
## Sources
- Named retail:
- `gmToolbarUI::ListenToElementMessage @ 0x004BEE90`
- `gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0`
- `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`
- `gmExaminationUI::SetActiveExamineUI @ 0x004AB420`
- `gmExaminationUI::UseTime @ 0x004AB530`
- `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`
- `BasicCreatureExamineUI::SetAppraiseInfo @ 0x004B3F70`
- `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`
- `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`
- `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`
- `gmFloatyExaminationUI::ResizeTo @ 0x004D4470`
- Retail LayoutDesc `0x2100006B`, root `0x100005F2`
(`gmFloatyExaminationUI`, authored size 310 x 400 pixels).
- ACE:
- `GameEventIdentifyObjectResponse`
- `AppraiseInfo.Write`
- `HookProfile.Write`
- ACViewer's `HookAppraisalProfile` representation, used as a second
interpretation check for the three-field hook profile.
The named retail client is the behavior oracle. ACE and ACViewer only clarify
the server packet and structure interpretation.
## Toolbar request
```text
on Examine button:
if selected object id != 0:
ClientUISystem.ExamineObject(selected id)
else:
enter TARGET_MODE_EXAMINE
```
This was already present in acdream before this slice and does not need a
second command path.
## Examination request lifetime
```text
on ExamineObject(guid):
if guid == 0:
return
if awaitingAppraisalId == 0:
ClientUISystem.IncrementBusyCount()
awaitingAppraisalId = guid
send CM_Item.Event_Appraise(guid)
clear pending profile pointer
```
Replacing one pending GUID does not add a second busy reference. The response
for the current pending GUID releases that one reference.
## IdentifyObjectResponse packet order
```text
read guid
read flags
read success
if IntStatsTable: read packed int table
if Int64StatsTable: read packed int64 table
if BoolStatsTable: read packed bool table
if FloatStatsTable: read packed double table
if StringStatsTable: read packed string table
if DidStatsTable: read packed DID table
if SpellBook: read spell list
if ArmorProfile: read armor profile
if CreatureProfile: read creature profile
if WeaponProfile: read weapon profile
if HookProfile: read flags, validLocations, ammoType (three u32)
if ArmorEnchantmentBitfield: read highlight/color (two u16)
if WeaponEnchantmentBitfield: read highlight/color (two u16)
if ResistEnchantmentBitfield: read highlight/color (two u16)
if ArmorLevels: read nine u32 armor levels
```
Every gated field is positional. A truncated gated field rejects the packet;
returning a partial profile would let later fields masquerade as earlier ones.
## Applying an appraisal
```text
SetAppraiseInfo(guid, profile):
if guid == 0:
return
if guid != awaitingAppraisalId and guid != currentAppraisalId:
return
firstResponse = false
if guid == awaitingAppraisalId:
awaitingAppraisalId = 0
ClientUISystem.DecrementBusyCount()
currentAppraisalId = guid
firstResponse = true
if profile has no CreatureProfile:
subview = item
else if profile has string property Template (5)
or int property EquipmentSetId (0x105):
subview = character/player
else:
subview = creature
object = ClientObjMaintSystem.GetWeenieObject(guid)
if object is absent:
return
titleName = profile string 0x34 if present
else object.GetObjectName(NAME_APPROPRIATE)
title = titleName with stack count
newlySelectedInSubview = guid != subview.currentObjectId
subview.Init(guid, object)
subview.SetAppraiseInfo(profile, newlySelectedInSubview)
SetActiveExamineUI(subview)
if firstResponse:
show examination window
```
Only the first response to an explicit request forces the window visible.
Background refreshes update the current object without reopening a closed
window.
## Subview and refresh behavior
```text
SetActiveExamineUI(selected):
hide item, creature, character, and spell subviews
show selected subview only
on time pulse:
if examination window is visible
and combat mode is not peace
and active subview is creature or character
and currentAppraisalId != 0
and 0.75 seconds elapsed:
send another Event_Appraise(currentAppraisalId)
```
When the examination window's selection-follow option is active, a selection
change appraises the new selected GUID; clearing selection hides the window.
That optional retail preference remains outside this slice.
## Item presentation order
`ItemExamineUI::SetAppraiseInfo` clears and rebuilds the authored item text in
this order:
1. icon;
2. value and burden (the EoR layout has no separate value/burden controls, so
these enter the main text);
3. inscription/signature;
4. tinkering/workmanship;
5. set and rating information;
6. weapon or armor profile;
7. defense and armor modifiers;
8. magic, special properties, usage and wield requirements;
9. caster/boost/healer/capacity/lock/mana/uses/craftsman details;
10. sale restriction, rare status, full magic, and description.
The main text and inscription areas use their authored scrollbars. A newly
selected object resets those scroll positions to the top; a refresh of the
same object preserves the user's position.
## Window behavior
The LayoutDesc root already contains retail's complete floaty window chrome,
close control, item/creature/character/spell subviews, and scrollbars.
`gmFloatyExaminationUI::ResizeTo` delegates to ordinary `UIElement::ResizeTo`
and persists width/height; it adds no special resize algorithm.
## Slice boundary
This slice ports the request lifetime, packet integrity, subview choice,
authored outer layout, basic report content, scrollbar ownership, and combat
refresh. Retail's exact 3D object/creature preview, all specialized
`ItemExamineUI` detail branches, selection-follow preference, and
`SetInscriptionEditableState` plus inscription submission remain explicit
AP-110 work. Until that permission/write transaction exists, the imported
inscription field is display-only rather than accepting edits that cannot be
sent.

View file

@ -35,7 +35,8 @@ internal sealed record LiveInventorySessionBindings(
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
ExternalContainerState? ExternalContainers);
ExternalContainerState? ExternalContainers,
Action<AppraiseInfoParser.Parsed>? OnAppraisal = null);
internal sealed record LiveCharacterSessionBindings(
CombatState Combat,
@ -157,6 +158,7 @@ internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
onUseDone: inventory.OnUseDone,
onAppraisal: inventory.OnAppraisal,
itemMana: inventory.ItemMana,
onConfirmationRequest: character.OnConfirmationRequest,
onConfirmationDone: character.OnConfirmationDone,

View file

@ -155,7 +155,7 @@ internal sealed class LiveSessionRuntimeFactory
MouseCapture = _interaction.GameplayInput.ResetSession,
PlayerPresentation = ResetPlayerPresentation,
TeleportTransit = _world.Teleport.ResetSession,
SessionDialogs = () => _ui.RetailUi?.ResetSessionDialogs(),
SessionDialogs = () => _ui.RetailUi?.ResetSessionTransientUi(),
ChatCommandTargets = () => _ui.RetailChat?.ResetSessionTargets(),
SettingsCharacterContext =
_interaction.Settings.RestoreDefaultCharacterContext,
@ -260,7 +260,14 @@ internal sealed class LiveSessionRuntimeFactory
_domain.ItemMana,
OnDesiredComponents: components =>
_player.DesiredComponents.Items = components,
ExternalContainers: _domain.ExternalContainers);
ExternalContainers: _domain.ExternalContainers,
OnAppraisal: appraisal =>
{
if (_ui.RetailUi is { } retailUi)
retailUi.HandleAppraisal(appraisal);
else
_interaction.ItemInteraction.AcceptAppraisalResponse(appraisal.Guid);
});
private LiveCharacterSessionBindings CreateCharacterBindings(
SkillTable? skillTable)

View file

@ -110,6 +110,8 @@ public sealed class ItemInteractionController : IDisposable
private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2;
private int _busyCount;
private uint _awaitingAppraisalId;
private uint _currentAppraisalId;
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
@ -222,6 +224,7 @@ public sealed class ItemInteractionController : IDisposable
public InteractionState InteractionState => _interactionState;
public int BusyCount => _busyCount;
public uint CurrentAppraisalId => _currentAppraisalId;
public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
@ -451,7 +454,7 @@ public sealed class ItemInteractionController : IDisposable
ClearTargetMode();
accepted = targetGuid != 0 && _sendExamine is not null;
if (accepted)
_sendExamine!(targetGuid);
RequestAppraisal(targetGuid);
break;
case InteractionModeKind.UseItemOnTarget:
accepted = AcquireTarget(targetGuid);
@ -492,7 +495,61 @@ public sealed class ItemInteractionController : IDisposable
return _interactionState.EnterExamine();
if (_sendExamine is null)
return false;
_sendExamine(selectedObjectId);
RequestAppraisal(selectedObjectId);
return true;
}
/// <summary>
/// Retail <c>gmExaminationUI::RecvNotice_ExamineObject @ 0x004AB7B0</c>.
/// One shared UI-busy reference covers the currently pending appraisal,
/// even when another examine request replaces its GUID before a response.
/// </summary>
private void RequestAppraisal(uint objectId)
{
if (objectId == 0 || _sendExamine is null)
return;
if (_awaitingAppraisalId == 0)
IncrementBusyCount();
_awaitingAppraisalId = objectId;
_sendExamine(objectId);
}
/// <summary>
/// Accepts only the pending or current appraisal, matching
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
/// </summary>
public AppraisalResponseAcceptance AcceptAppraisalResponse(uint objectId)
{
if (objectId == 0
|| (objectId != _awaitingAppraisalId
&& objectId != _currentAppraisalId))
return default;
bool firstResponse = objectId == _awaitingAppraisalId;
if (firstResponse)
{
_awaitingAppraisalId = 0;
if (_busyCount > 0)
_busyCount--;
_currentAppraisalId = objectId;
StateChanged?.Invoke();
}
return new AppraisalResponseAcceptance(
Accepted: true,
FirstResponse: firstResponse);
}
/// <summary>
/// Retail combat-time creature/player refresh sends CM_Item directly and
/// therefore does not acquire a second UI-busy reference.
/// </summary>
public bool RefreshCurrentAppraisal()
{
if (_currentAppraisalId == 0 || _sendExamine is null)
return false;
_sendExamine(_currentAppraisalId);
return true;
}
@ -1240,6 +1297,8 @@ public sealed class ItemInteractionController : IDisposable
_pendingBackpackPlacement = null;
_useReservationGeneration++;
_busyCount = 0;
_awaitingAppraisalId = 0;
_currentAppraisalId = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
@ -1258,6 +1317,10 @@ public sealed class ItemInteractionController : IDisposable
failures);
}
public readonly record struct AppraisalResponseAcceptance(
bool Accepted,
bool FirstResponse);
private static void DispatchAll(Action? listeners, List<Exception> failures)
{
if (listeners is null)

View file

@ -0,0 +1,712 @@
using System.Globalization;
using System.Numerics;
using System.Text;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained port of retail <c>gmExaminationUI</c>. The controller owns the
/// appraisal response/subview lifecycle; the imported LayoutDesc owns all
/// chrome, geometry, fonts, and scrollbars.
/// </summary>
public sealed class AppraisalUiController : IRetainedPanelController
{
public const uint LayoutId = 0x2100006Bu;
public const uint RootId = 0x100005F2u;
public const uint CloseId = 0x100005F3u;
public const uint TitleId = 0x1000012Du;
public const uint ItemPanelId = 0x1000012Eu;
public const uint ItemTextId = 0x1000013Cu;
public const uint ItemScrollbarId = 0x1000013Du;
public const uint InscriptionTextId = 0x1000013Eu;
public const uint SignatureTextId = 0x1000013Fu;
public const uint InscriptionScrollbarId = 0x1000046Eu;
public const uint CreaturePanelId = 0x10000140u;
public const uint SpellPanelId = 0x10000153u;
private const uint TemplateStringProperty = 5u;
private const uint CharacterMarkerIntProperty = 0x105u;
private const uint DisplayedNameStringProperty = 0x34u;
private const double CreatureRefreshSeconds = 0.75;
private readonly ImportedLayout _layout;
private readonly ClientObjectTable _objects;
private readonly ItemInteractionController _interaction;
private readonly CombatState _combat;
private readonly Spellbook _spellbook;
private readonly Action _show;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
private readonly UiElement? _spellPanel;
private readonly UiText _title;
private readonly UiText _itemText;
private readonly UiText? _inscriptionText;
private readonly UiField? _inscriptionField;
private readonly UiText? _signature;
private readonly UiButton? _close;
private AppraisalView _activeView;
private uint _itemObjectId;
private uint _creatureObjectId;
private uint _characterObjectId;
private string _titleValue = string.Empty;
private string _itemReport = string.Empty;
private string _inscriptionValue = string.Empty;
private string _signatureValue = string.Empty;
private double _refreshElapsed;
private bool _disposed;
private AppraisalUiController(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Action show,
Action close,
UiElement itemPanel,
UiElement creaturePanel,
UiText title,
UiText itemText)
{
_layout = layout;
_objects = objects;
_interaction = interaction;
_combat = combat;
_spellbook = spellbook;
_show = show;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
_spellPanel = layout.FindElement(SpellPanelId);
_title = title;
_itemText = itemText;
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
_close = layout.FindElement(CloseId) as UiButton;
_title.LinesProvider = () =>
[new UiText.Line(_titleValue, _title.DefaultColor)];
ConfigureScrollableText(_itemText, ItemScrollbarId, () => _itemReport);
if (_inscriptionText is not null)
ConfigureScrollableText(
_inscriptionText,
InscriptionScrollbarId,
() => _inscriptionValue);
if (_inscriptionField is not null)
{
_inscriptionField.SetText(string.Empty);
// Retail conditionally enables this field only after its ownership
// and inscription-permission checks. That write transaction remains
// tracked by AP-110; keep the imported field display-only until the
// matching SetInscriptionEditableState/submit path is present.
_inscriptionField.Enabled = false;
}
if (_signature is not null)
_signature.LinesProvider = () =>
[new UiText.Line(_signatureValue, _signature.DefaultColor)];
if (_close is not null)
_close.OnClick = close;
SetActiveView(AppraisalView.Item);
}
public AppraisalView ActiveView => _activeView;
public uint CurrentObjectId => _interaction.CurrentAppraisalId;
public static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
ItemInteractionController interaction,
CombatState combat,
Spellbook spellbook,
Action show,
Action close)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(interaction);
ArgumentNullException.ThrowIfNull(combat);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(show);
ArgumentNullException.ThrowIfNull(close);
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
|| layout.FindElement(TitleId) is not UiText title
|| layout.FindElement(ItemTextId) is not UiText itemText)
return null;
return new AppraisalUiController(
layout,
objects,
interaction,
combat,
spellbook,
show,
close,
itemPanel,
creaturePanel,
title,
itemText);
}
/// <summary>
/// Applies the response ordering and first-response visibility rule from
/// <c>gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0</c>.
/// </summary>
public bool Apply(AppraiseInfoParser.Parsed appraisal)
{
ItemInteractionController.AppraisalResponseAcceptance acceptance =
_interaction.AcceptAppraisalResponse(appraisal.Guid);
if (!acceptance.Accepted)
return false;
ClientObject? obj = _objects.Get(appraisal.Guid);
if (obj is null)
return false;
_titleValue = BuildTitle(obj, appraisal.Properties);
AppraisalView view = SelectView(appraisal);
bool newlySelected = view switch
{
AppraisalView.Item => _itemObjectId != appraisal.Guid,
AppraisalView.Creature => _creatureObjectId != appraisal.Guid,
AppraisalView.Character => _characterObjectId != appraisal.Guid,
_ => false,
};
switch (view)
{
case AppraisalView.Item:
_itemObjectId = appraisal.Guid;
ApplyItem(obj, appraisal, newlySelected);
break;
case AppraisalView.Creature:
_creatureObjectId = appraisal.Guid;
ApplyCreature(obj, appraisal, character: false, newlySelected);
break;
case AppraisalView.Character:
_characterObjectId = appraisal.Guid;
ApplyCreature(obj, appraisal, character: true, newlySelected);
break;
}
SetActiveView(view);
_refreshElapsed = 0;
if (acceptance.FirstResponse)
_show();
return true;
}
public void Tick(double deltaSeconds)
{
if (!_layout.Root.Visible
|| _activeView is not (AppraisalView.Creature or AppraisalView.Character)
|| _combat.CurrentMode == CombatMode.NonCombat
|| CurrentObjectId == 0)
{
_refreshElapsed = 0;
return;
}
if (!double.IsFinite(deltaSeconds) || deltaSeconds <= 0)
return;
_refreshElapsed += deltaSeconds;
if (_refreshElapsed < CreatureRefreshSeconds)
return;
_refreshElapsed %= CreatureRefreshSeconds;
_interaction.RefreshCurrentAppraisal();
}
public void ResetSession()
{
_titleValue = string.Empty;
_itemReport = string.Empty;
_inscriptionValue = string.Empty;
_inscriptionField?.SetText(string.Empty);
_signatureValue = string.Empty;
_itemObjectId = 0;
_creatureObjectId = 0;
_characterObjectId = 0;
_refreshElapsed = 0;
SetActiveView(AppraisalView.Item);
ClearCreatureText();
}
private void ApplyItem(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool newlySelected)
{
_itemReport = AppraisalTextFormatter.BuildItem(
obj,
appraisal,
ResolveSpellName);
_inscriptionValue = GetString(appraisal.Properties, 7u);
_inscriptionField?.SetText(_inscriptionValue);
string scribe = GetString(appraisal.Properties, 8u);
_signatureValue = string.IsNullOrWhiteSpace(scribe)
? string.Empty
: $"Inscribed by {scribe}";
if (newlySelected)
{
_itemText.Scroll.SetScrollY(0);
_inscriptionText?.Scroll.SetScrollY(0);
}
}
private void ApplyCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool character,
bool newlySelected)
{
ClearCreatureText();
PropertyBundle p = appraisal.Properties;
SetText(0x1000014Au, character
? GetString(p, 4u)
: AppraisalTextFormatter.CreatureTypeName(GetInt(p, 2u)));
SetText(0x1000014Bu, GetString(p, 2u));
SetText(0x1000014Cu, GetString(p, 14u));
string report = AppraisalTextFormatter.BuildCreature(
obj,
appraisal,
character);
SetText(0x1000014Eu, report, scrollable: true);
if (character)
{
SetText(0x10000150u, GetString(p, 4u));
SetText(0x10000151u, GetString(p, 47u));
SetText(0x10000152u, GetString(p, 11u));
}
SetText(0x1000053Au, appraisal.Success
? string.Empty
: "Assessment incomplete");
if (newlySelected)
ResetCreatureScroll();
}
private void ConfigureScrollableText(
UiText text,
uint scrollbarId,
Func<string> value)
{
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = true;
text.ClickThrough = false;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value());
if (_layout.FindElement(scrollbarId) is UiScrollbar scrollbar)
scrollbar.Model = text.Scroll;
}
private void SetText(uint elementId, string value, bool scrollable = false)
{
if (_layout.FindElement(elementId) is not UiText text)
return;
text.PreserveEndOnLayout = false;
text.WheelScrollEnabled = scrollable;
text.ClickThrough = !scrollable;
text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
if (scrollable)
{
UiScrollbar? scrollbar = Descendants(text.Parent)
.OfType<UiScrollbar>()
.FirstOrDefault(candidate => candidate.Visible);
if (scrollbar is not null)
scrollbar.Model = text.Scroll;
}
}
private void ClearCreatureText()
{
foreach (uint id in new uint[]
{
0x1000014Au, 0x1000014Bu, 0x1000014Cu, 0x1000014Eu,
0x10000150u, 0x10000151u, 0x10000152u, 0x1000053Au,
})
if (_layout.FindElement(id) is UiText text)
text.LinesProvider = static () => Array.Empty<UiText.Line>();
}
private void ResetCreatureScroll()
{
foreach (UiText text in Descendants(_creaturePanel).OfType<UiText>())
text.Scroll.SetScrollY(0);
}
private void SetActiveView(AppraisalView view)
{
_activeView = view;
_itemPanel.Visible = view == AppraisalView.Item;
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
if (_spellPanel is not null)
_spellPanel.Visible = false;
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
if (creatureInfo is not null)
creatureInfo.Visible = view == AppraisalView.Creature;
if (characterInfo is not null)
characterInfo.Visible = view == AppraisalView.Character;
}
private string? ResolveSpellName(uint spellId)
=> _spellbook.TryGetMetadata(spellId & 0x7FFF_FFFFu, out SpellMetadata metadata)
? metadata.Name
: null;
private static AppraisalView SelectView(AppraiseInfoParser.Parsed appraisal)
{
if (appraisal.CreatureProfile is null)
return AppraisalView.Item;
return appraisal.Properties.Strings.ContainsKey(TemplateStringProperty)
|| appraisal.Properties.Ints.ContainsKey(CharacterMarkerIntProperty)
? AppraisalView.Character
: AppraisalView.Creature;
}
private static string BuildTitle(ClientObject obj, PropertyBundle properties)
{
string name = GetString(properties, DisplayedNameStringProperty);
if (string.IsNullOrWhiteSpace(name))
name = obj.GetAppropriateName();
return obj.StackSize > 1
? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}"
: name;
}
private static int GetInt(PropertyBundle properties, uint id)
=> properties.Ints.TryGetValue(id, out int value) ? value : 0;
private static string GetString(PropertyBundle properties, uint id)
=> properties.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
private static IEnumerable<UiElement> Descendants(UiElement? root)
{
if (root is null)
yield break;
foreach (UiElement child in root.Children)
{
yield return child;
foreach (UiElement descendant in Descendants(child))
yield return descendant;
}
}
public void OnShown()
{
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_close is not null)
_close.OnClick = null;
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType<UiScrollbar>())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
&& ReferenceEquals(scrollbar.Model, _inscriptionText.Scroll)))
scrollbar.Model = null;
}
}
public enum AppraisalView
{
Item,
Creature,
Character,
}
/// <summary>Pure retail-ordered text projection for the authored examination fields.</summary>
public static class AppraisalTextFormatter
{
private const uint Level = 25u;
private const uint ArmorLevel = 28u;
private const uint Damage = 44u;
private const uint DamageType = 45u;
private const uint WeaponTime = 49u;
private const uint Structure = 92u;
private const uint MaxStructure = 91u;
private const uint Workmanship = 105u;
private const uint CurrentMana = 107u;
private const uint MaximumMana = 108u;
private const uint Difficulty = 109u;
private const uint WieldRequirement = 158u;
private const uint WieldSkill = 159u;
private const uint WieldDifficulty = 160u;
private const uint RareId = 17u;
public static string BuildItem(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
Func<uint, string?> resolveSpellName)
{
ArgumentNullException.ThrowIfNull(obj);
ArgumentNullException.ThrowIfNull(resolveSpellName);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Value", obj.Value != 0 ? obj.Value : GetInt(p, 19u), "N0");
Append(text, "Burden", obj.Burden != 0 ? obj.Burden : GetInt(p, 5u), "N0");
int workmanship = GetInt(p, Workmanship);
if (workmanship != 0 || obj.Workmanship != 0)
AppendLine(text, "Workmanship",
(workmanship != 0 ? workmanship : obj.Workmanship)
.ToString("0.##", CultureInfo.CurrentCulture));
if (appraisal.WeaponProfile is { } weapon)
{
AppendLine(text, "Damage",
$"{weapon.Damage.ToString(CultureInfo.CurrentCulture)} {DamageTypeName(weapon.DamageType)}");
AppendLine(text, "Speed", weapon.WeaponTime.ToString(CultureInfo.CurrentCulture));
AppendPercent(text, "Damage Variance", weapon.DamageVariance);
AppendPercent(text, "Attack Bonus", weapon.WeaponOffense - 1d);
}
else
{
Append(text, "Damage", GetInt(p, Damage));
int damageType = GetInt(p, DamageType);
if (damageType != 0)
AppendLine(text, "Damage Type", DamageTypeName((uint)damageType));
Append(text, "Speed", GetInt(p, WeaponTime));
}
if (appraisal.ArmorProfile is { } armor)
{
Append(text, "Armor Level", GetInt(p, ArmorLevel));
AppendProtection(text, "Slashing", armor.SlashingProtection);
AppendProtection(text, "Piercing", armor.PiercingProtection);
AppendProtection(text, "Bludgeoning", armor.BludgeoningProtection);
AppendProtection(text, "Fire", armor.FireProtection);
AppendProtection(text, "Cold", armor.ColdProtection);
AppendProtection(text, "Acid", armor.AcidProtection);
AppendProtection(text, "Electric", armor.LightningProtection);
AppendProtection(text, "Nether", armor.NetherProtection);
}
Append(text, "Level", GetInt(p, Level));
Append(text, "Difficulty", GetInt(p, Difficulty));
int currentMana = GetInt(p, CurrentMana);
int maxMana = GetInt(p, MaximumMana);
if (currentMana != 0 || maxMana != 0)
AppendLine(text, "Mana",
$"{currentMana.ToString("N0", CultureInfo.CurrentCulture)} / {maxMana.ToString("N0", CultureInfo.CurrentCulture)}");
int currentUses = obj.Structure != 0 ? obj.Structure : GetInt(p, Structure);
int maxUses = obj.MaxStructure != 0 ? obj.MaxStructure : GetInt(p, MaxStructure);
if (currentUses != 0 || maxUses != 0)
AppendLine(text, "Uses",
maxUses > 0
? $"{currentUses.ToString(CultureInfo.CurrentCulture)} / {maxUses.ToString(CultureInfo.CurrentCulture)}"
: currentUses.ToString(CultureInfo.CurrentCulture));
if (GetInt(p, WieldRequirement) != 0)
AppendLine(
text,
"Wield Requirement",
$"{SkillName(GetInt(p, WieldSkill))} {GetInt(p, WieldDifficulty).ToString(CultureInfo.CurrentCulture)}");
if (appraisal.HookProfile is { } hook)
{
if ((hook.Flags & 0x1u) != 0)
AppendFlag(text, "Inscribable");
if ((hook.Flags & 0x2u) != 0)
AppendFlag(text, "Healer");
if ((hook.Flags & 0x4u) != 0)
AppendFlag(text, "Food");
if ((hook.Flags & 0x8u) != 0)
AppendFlag(text, "Lockpick");
}
foreach (uint rawSpellId in appraisal.SpellBook)
{
uint spellId = rawSpellId & 0x7FFF_FFFFu;
AppendFlag(text, resolveSpellName(spellId) ?? $"Spell {spellId}");
}
if (GetInt(p, RareId) != 0)
AppendFlag(text, "Rare Item");
string use = GetString(p, 14u);
if (!string.IsNullOrWhiteSpace(use))
AppendParagraph(text, use);
string shortDescription = GetString(p, 15u);
if (!string.IsNullOrWhiteSpace(shortDescription))
AppendParagraph(text, shortDescription);
string longDescription = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(longDescription)
&& !string.Equals(longDescription, shortDescription, StringComparison.Ordinal))
AppendParagraph(text, longDescription);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string BuildCreature(
ClientObject obj,
AppraiseInfoParser.Parsed appraisal,
bool character)
{
ArgumentNullException.ThrowIfNull(obj);
PropertyBundle p = appraisal.Properties;
var text = new StringBuilder();
Append(text, "Level", GetInt(p, Level));
if (appraisal.CreatureProfile is { } creature)
{
AppendLine(text, "Health",
$"{creature.Health.ToString("N0", CultureInfo.CurrentCulture)} / {creature.HealthMax.ToString("N0", CultureInfo.CurrentCulture)}");
AppendOptional(text, "Stamina", creature.Stamina, creature.StaminaMax);
AppendOptional(text, "Mana", creature.Mana, creature.ManaMax);
AppendOptional(text, "Strength", creature.Strength);
AppendOptional(text, "Endurance", creature.Endurance);
AppendOptional(text, "Coordination", creature.Coordination);
AppendOptional(text, "Quickness", creature.Quickness);
AppendOptional(text, "Focus", creature.Focus);
AppendOptional(text, "Self", creature.Self);
}
string title = GetString(p, 2u);
if (character && !string.IsNullOrWhiteSpace(title))
AppendLine(text, "Title", title);
string description = GetString(p, 16u);
if (!string.IsNullOrWhiteSpace(description))
AppendParagraph(text, description);
if (!appraisal.Success && text.Length == 0)
text.Append("Assessment incomplete");
return text.ToString().TrimEnd();
}
public static string CreatureTypeName(int creatureType)
=> creatureType == 0
? string.Empty
: $"Creature type {creatureType.ToString(CultureInfo.CurrentCulture)}";
private static void AppendOptional(
StringBuilder text,
string label,
uint? current,
uint? maximum = null)
{
if (current is null)
return;
AppendLine(
text,
label,
maximum is { } max
? $"{current.Value.ToString("N0", CultureInfo.CurrentCulture)} / {max.ToString("N0", CultureInfo.CurrentCulture)}"
: current.Value.ToString("N0", CultureInfo.CurrentCulture));
}
private static void Append(
StringBuilder text,
string label,
int value,
string? format = null)
{
if (value == 0)
return;
AppendLine(
text,
label,
value.ToString(format ?? "0", CultureInfo.CurrentCulture));
}
private static void AppendPercent(StringBuilder text, string label, double value)
{
if (Math.Abs(value) < 0.000001)
return;
AppendLine(text, label, value.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture));
}
private static void AppendProtection(StringBuilder text, string label, float value)
{
if (Math.Abs(value - 1f) < 0.0001f)
return;
AppendLine(text, label, value.ToString("0.00x", CultureInfo.CurrentCulture));
}
private static void AppendFlag(StringBuilder text, string value)
=> AppendParagraph(text, value);
private static void AppendLine(StringBuilder text, string label, string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (text.Length != 0)
text.Append('\n');
text.Append(label).Append(": ").Append(value);
}
private static void AppendParagraph(StringBuilder text, string value)
{
if (text.Length != 0)
text.Append("\n\n");
text.Append(value);
}
private static int GetInt(PropertyBundle p, uint id)
=> p.Ints.TryGetValue(id, out int value) ? value : 0;
private static string GetString(PropertyBundle p, uint id)
=> p.Strings.TryGetValue(id, out string? value) ? value : string.Empty;
private static string DamageTypeName(uint type) => type switch
{
1u => "Slashing",
2u => "Piercing",
4u => "Bludgeoning",
8u => "Cold",
16u => "Fire",
32u => "Acid",
64u => "Electric",
1024u => "Nether",
_ => $"Type {type.ToString(CultureInfo.CurrentCulture)}",
};
private static string SkillName(int skill) => skill switch
{
1 => "Axe",
3 => "Dagger",
6 => "Mace",
9 => "Spear",
10 => "Staff",
12 => "Sword",
13 => "Thrown Weapons",
14 => "Unarmed Combat",
15 => "Arcane Lore",
18 => "Magic Defense",
19 => "Mana Conversion",
21 => "Item Tinkering",
22 => "Assess Person",
23 => "Deception",
24 => "Healing",
27 => "Lockpick",
30 => "Creature Enchantment",
31 => "Item Enchantment",
32 => "Life Magic",
34 => "War Magic",
40 => "Light Weapons",
41 => "Heavy Weapons",
42 => "Finesse Weapons",
43 => "Missile Weapons",
44 => "Shield",
45 => "Dual Wield",
46 => "Recklessness",
47 => "Sneak Attack",
48 => "Dirty Fighting",
54 => "Void Magic",
55 => "Summoning",
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
};
}

View file

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

View file

@ -234,6 +234,7 @@ public sealed class RetailUiRuntime : IDisposable
MountToolbar();
MountCombat();
MountSpellbook();
MountAppraisal();
MountEffects();
MountIndicatorDetailPanels();
MountIndicators();
@ -293,6 +294,7 @@ public sealed class RetailUiRuntime : IDisposable
public CombatUiController? CombatUiController { get; private set; }
public SpellcastingUiController? SpellcastingUiController { get; private set; }
public SpellbookWindowController? SpellbookWindowController { get; private set; }
public AppraisalUiController? AppraisalController { get; private set; }
public EffectsUiController? PositiveEffectsController { get; private set; }
public EffectsUiController? NegativeEffectsController { get; private set; }
public LinkStatusUiController? LinkStatusUiController { get; private set; }
@ -340,6 +342,7 @@ public sealed class RetailUiRuntime : IDisposable
FpsController?.Tick();
_vividTargetIndicator?.Tick();
SpellbookWindowController?.Tick();
AppraisalController?.Tick(deltaSeconds);
SpellcastingUiController?.Tick();
PositiveEffectsController?.Tick();
NegativeEffectsController?.Tick();
@ -391,6 +394,11 @@ public sealed class RetailUiRuntime : IDisposable
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
public bool HandleAppraisal(AppraiseInfoParser.Parsed appraisal)
=> AppraisalController is { } controller
? controller.Apply(appraisal)
: ItemInteraction.AcceptAppraisalResponse(appraisal.Guid).Accepted;
public uint ShowConfirmation(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
@ -419,6 +427,13 @@ public sealed class RetailUiRuntime : IDisposable
}
}
public void ResetSessionTransientUi()
{
ResetSessionDialogs();
AppraisalController?.ResetSession();
_panelUi.SetPanelVisibility(RetailPanelCatalog.Examination, visible: false);
}
public void UpdateCursor(IEnumerable<IMouse> mice)
{
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
@ -938,6 +953,67 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
}
private void MountAppraisal()
{
ImportedLayout? layout = Import(
AppraisalUiController.LayoutId,
AppraisalUiController.RootId);
if (layout is null)
{
Console.WriteLine(
"[M4] examination: LayoutDesc 0x2100006B root 0x100005F2 not found.");
return;
}
AppraisalUiController? controller = AppraisalUiController.Bind(
layout,
_bindings.Inventory.Objects,
_bindings.Inventory.ItemInteraction,
_bindings.Combat.State,
_bindings.Magic.Spellbook,
show: () => _panelUi.SetPanelVisibility(
RetailPanelCatalog.Examination,
visible: true),
close: () => CloseWindow(WindowNames.Examination));
if (controller is null)
{
Console.WriteLine(
"[M4] examination: required authored controls are missing.");
return;
}
AppraisalController = controller;
UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount(
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.Examination,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
ResizeX = true,
ResizeY = true,
MinWidth = 310f,
MinHeight = 400f,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
ContentClickThrough = false,
Controller = controller,
});
_panelUi.RegisterMainPanel(
RetailPanelCatalog.Examination,
WindowNames.Examination,
handle);
Console.WriteLine(
"[M4] retail examination window from LayoutDesc 0x2100006B.");
}
private void MountEffects()
{
MountEffectsInstance(positive: true);

View file

@ -21,4 +21,5 @@ public static class WindowNames
public const string LinkStatus = "link-status";
public const string MiniGame = "mini-game";
public const string Vitae = "vitae";
public const string Examination = "examination";
}

View file

@ -73,6 +73,7 @@ public static class GameEventWiring
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null,
Action<AppraiseInfoParser.Parsed>? onAppraisal = null,
ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
@ -432,10 +433,18 @@ public static class GameEventWiring
registrar.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
if (p is null) return;
// Merge parsed properties into the item if we know about it.
if (items.Get(p.Value.Guid) is not null)
if (p.Value.Success && items.Get(p.Value.Guid) is not null)
items.UpdateProperties(p.Value.Guid, p.Value.Properties);
if (p.Value.CreatureProfile is { HealthMax: > 0u } creature)
combat.OnUpdateHealth(
p.Value.Guid,
Math.Clamp(
(float)creature.Health / creature.HealthMax,
0f,
1f));
onAppraisal?.Invoke(p.Value);
// Spellbook from appraise: for caster items / scrolls this is
// the cast-on-use list. The local player's full learned
// spellbook arrives via PlayerDescription (0x0013), which uses

View file

@ -34,7 +34,7 @@ namespace AcDream.Core.Net.Messages;
/// if (flags &amp; StringStatsTable) packedTable&lt;u32 key, string16L value&gt;
/// if (flags &amp; DidStatsTable) packedTable&lt;u32 key, u32 value&gt;
/// if (flags &amp; SpellBook) u32 count, u32[count] spellIds
/// ...armor/creature/weapon/hook profile blobs follow (deferred)
/// ...armor/creature/weapon/hook profile blobs follow
/// </code>
/// </para>
///
@ -48,10 +48,9 @@ namespace AcDream.Core.Net.Messages;
/// </para>
///
/// <para>
/// Returns a fully-populated <see cref="PropertyBundle"/>. Profile
/// blobs (ArmorProfile / CreatureProfile / WeaponProfile / HookProfile)
/// are recognised via the flag bits but not yet deserialized — the
/// parser seeks past them to keep the flag walk aligned.
/// Returns the complete gated appraisal payload. A malformed gated field
/// rejects the packet rather than returning a partial profile whose cursor
/// could be misaligned for every later field.
/// </para>
/// </summary>
public static class AppraiseInfoParser
@ -107,6 +106,12 @@ public static class AppraiseInfoParser
double WeaponOffense,
uint MaxVelocityEstimated);
/// <summary>Hook-container item metadata serialized as three consecutive u32 values.</summary>
public readonly record struct HookProfile(
uint Flags,
uint ValidLocations,
uint AmmoType);
/// <summary>Creature vitals + (optionally) attributes from a CreatureProfile blob.</summary>
public readonly record struct CreatureProfile(
uint Flags,
@ -134,6 +139,7 @@ public static class AppraiseInfoParser
ArmorProfile? ArmorProfile,
CreatureProfile? CreatureProfile,
WeaponProfile? WeaponProfile,
HookProfile? HookProfile,
ArmorLevel? ArmorLevels,
(ushort Highlight, ushort Color)? ArmorEnchantments,
(ushort Highlight, ushort Color)? WeaponEnchantments,
@ -160,6 +166,7 @@ public static class AppraiseInfoParser
ArmorProfile? armor = null;
CreatureProfile? creature = null;
WeaponProfile? weapon = null;
HookProfile? hook = null;
ArmorLevel? levels = null;
(ushort H, ushort C)? armorEnc = null, weaponEnc = null, resistEnc = null;
@ -187,7 +194,8 @@ public static class AppraiseInfoParser
creature = ReadCreatureProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponProfile))
weapon = ReadWeaponProfile(payload, ref pos);
// HookProfile (0x200) — not deserialized; would need HookProfile struct port.
if (flags.HasFlag(IdentifyResponseFlags.HookProfile))
hook = ReadHookProfile(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.ArmorEnchantmentBitfield))
armorEnc = ReadEnchantmentBitfield(payload, ref pos);
if (flags.HasFlag(IdentifyResponseFlags.WeaponEnchantmentBitfield))
@ -199,12 +207,12 @@ public static class AppraiseInfoParser
}
catch (FormatException)
{
// Malformed table — return what we got so far.
return null;
}
return new Parsed(
guid, flags, success != 0, bundle, spellBook,
armor, creature, weapon, levels,
armor, creature, weapon, hook, levels,
armorEnc, weaponEnc, resistEnc);
}
@ -252,6 +260,12 @@ public static class AppraiseInfoParser
MaxVelocityEstimated: ReadU32(src, ref pos));
}
private static HookProfile ReadHookProfile(ReadOnlySpan<byte> src, ref int pos)
=> new(
Flags: ReadU32(src, ref pos),
ValidLocations: ReadU32(src, ref pos),
AmmoType: ReadU32(src, ref pos));
private static CreatureProfile ReadCreatureProfile(ReadOnlySpan<byte> src, ref int pos)
{
uint flags = ReadU32(src, ref pos);

View file

@ -301,15 +301,54 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
Assert.Equal(new[] { Player }, h.Examines);
Assert.Equal(1, h.Controller.BusyCount);
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
h.Controller.OfferPrimaryClick(Pack));
Assert.Equal(new[] { Player, Pack }, h.Examines);
Assert.Equal(1, h.Controller.BusyCount);
Assert.False(h.Controller.IsAnyTargetModeActive);
}
[Fact]
public void AppraisalResponse_releasesOneBusyReferenceAndAcceptsCurrentRefresh()
{
var h = new Harness();
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
Assert.Equal(1, h.Controller.BusyCount);
Assert.False(h.Controller.AcceptAppraisalResponse(Player).Accepted);
Assert.Equal(1, h.Controller.BusyCount);
var first = h.Controller.AcceptAppraisalResponse(Pack);
Assert.True(first.Accepted);
Assert.True(first.FirstResponse);
Assert.Equal(0, h.Controller.BusyCount);
Assert.Equal(Pack, h.Controller.CurrentAppraisalId);
var refresh = h.Controller.AcceptAppraisalResponse(Pack);
Assert.True(refresh.Accepted);
Assert.False(refresh.FirstResponse);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void RefreshCurrentAppraisal_sendsWithoutAcquiringBusyReference()
{
var h = new Harness();
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Pack));
Assert.True(h.Controller.AcceptAppraisalResponse(Pack).Accepted);
Assert.True(h.Controller.RefreshCurrentAppraisal());
Assert.Equal(new[] { Pack, Pack }, h.Examines);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{

View file

@ -0,0 +1,192 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class AppraisalUiControllerTests
{
private const uint ObjectId = 0x50000001u;
[Fact]
public void ItemResponse_UsesAuthoredItemSubviewTitleAndScrollbars()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Atlan Weapon",
Type = ItemType.MeleeWeapon,
Value = 1250,
Burden = 350,
});
var sent = new List<uint>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
int closed = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => shown++,
() => closed++)!;
Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
var properties = new PropertyBundle();
properties.Strings[16u] = "A finely balanced weapon.";
properties.Strings[7u] = "Remember the fallen.";
properties.Strings[8u] = "Tester";
Assert.True(controller.Apply(Parsed(properties)));
Assert.Equal(1, shown);
Assert.Equal(AppraisalView.Item, controller.ActiveView);
Assert.Equal(0, interaction.BusyCount);
UiText title = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.TitleId));
Assert.Equal("Atlan Weapon", Assert.Single(title.LinesProvider()).Text);
UiText itemText = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.ItemTextId));
string report = string.Join('\n', itemText.LinesProvider().Select(line => line.Text));
Assert.Contains("Value: 1,250", report);
Assert.Contains("Burden: 350", report);
Assert.Contains("A finely balanced weapon.", report);
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(AppraisalUiController.ItemScrollbarId));
Assert.Same(itemText.Scroll, scrollbar.Model);
UiField inscription = Assert.IsType<UiField>(
layout.FindElement(AppraisalUiController.InscriptionTextId));
Assert.Contains("Remember the fallen", inscription.Text);
Assert.False(inscription.Enabled);
UiText signature = Assert.IsType<UiText>(
layout.FindElement(AppraisalUiController.SignatureTextId));
Assert.Equal("Inscribed by Tester", Assert.Single(signature.LinesProvider()).Text);
((UiButton)layout.FindElement(AppraisalUiController.CloseId)!).OnClick!.Invoke();
Assert.Equal(1, closed);
}
[Fact]
public void CreatureResponse_SelectsCreatureSubviewAndRefreshesInCombatWithoutBusy()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = ObjectId,
Name = "Drudge Slinker",
Type = ItemType.Creature,
});
var sent = new List<uint>();
using var interaction = NewInteraction(objects, sent);
var combat = new CombatState();
int shown = 0;
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
combat,
new Spellbook(),
() => shown++,
() => { })!;
interaction.ExamineSelectedOrEnterMode(ObjectId);
var properties = new PropertyBundle();
properties.Ints[25u] = 12;
var creature = new AppraiseInfoParser.CreatureProfile(
Flags: 0,
Health: 80,
HealthMax: 100,
Strength: null,
Endurance: null,
Quickness: null,
Coordination: null,
Focus: null,
Self: null,
Stamina: null,
Mana: null,
StaminaMax: null,
ManaMax: null,
AttributeHighlights: null,
AttributeColors: null);
Assert.True(controller.Apply(Parsed(properties, creature)));
Assert.Equal(AppraisalView.Creature, controller.ActiveView);
Assert.Equal(1, shown);
Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
Assert.True(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
combat.SetCombatMode(CombatMode.Melee);
controller.Tick(0.74);
Assert.Single(sent);
controller.Tick(0.01);
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
Assert.Equal(0, interaction.BusyCount);
layout.Root.Visible = false;
controller.Tick(0.75);
Assert.Equal(new[] { ObjectId, ObjectId }, sent);
Assert.True(controller.Apply(Parsed(properties, creature)));
Assert.Equal(1, shown);
}
[Fact]
public void ResponseForNeitherPendingNorCurrent_IsIgnored()
{
ImportedLayout layout = FixtureLoader.LoadExamination();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = ObjectId, Name = "Item" });
using var interaction = NewInteraction(objects, []);
using AppraisalUiController controller = AppraisalUiController.Bind(
layout,
objects,
interaction,
new CombatState(),
new Spellbook(),
() => throw new InvalidOperationException("must not show"),
() => { })!;
Assert.False(controller.Apply(Parsed(new PropertyBundle())));
Assert.Equal(0u, controller.CurrentObjectId);
}
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
List<uint> sent)
=> new(
objects,
playerGuid: () => 0x50000002u,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: sent.Add);
private static AppraiseInfoParser.Parsed Parsed(
PropertyBundle properties,
AppraiseInfoParser.CreatureProfile? creature = null)
=> new(
Guid: ObjectId,
Flags: creature is null
? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable
: AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile,
Success: true,
Properties: properties,
SpellBook: [],
ArmorProfile: null,
CreatureProfile: creature,
WeaponProfile: null,
HookProfile: null,
ArmorLevels: null,
ArmorEnchantments: null,
WeaponEnchantments: null,
ResistEnchantments: null);
}

View file

@ -107,6 +107,12 @@ public static class FixtureLoader
public static ElementInfo LoadComponentRowTemplateInfos()
=> LoadInfos("component_row_21000033_10000467.json");
public static ImportedLayout LoadExamination()
=> LayoutImporter.Build(LoadExaminationInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadExaminationInfos()
=> LoadInfos("examine_2100006B_100005F2.json");
public static ImportedLayout LoadPowerbar()
=> LayoutImporter.Build(LoadPowerbarInfos(), _ => (0u, 0, 0), null);

View file

@ -68,6 +68,29 @@ public sealed class RetailFixtureConformanceTests
Assert.NotNull(Find(root, CharacterStatController.TitlesPageId));
}
[Fact]
public void ExaminationFixture_ContainsRetailChromeSubviewsAndScrollbars()
{
ElementInfo root = FixtureLoader.LoadExaminationInfos();
Assert.Equal(AppraisalUiController.RootId, root.Id);
Assert.Equal(310f, root.Width);
Assert.Equal(400f, root.Height);
Assert.NotNull(Find(root, AppraisalUiController.CloseId));
Assert.NotNull(Find(root, AppraisalUiController.TitleId));
Assert.NotNull(Find(root, AppraisalUiController.ItemPanelId));
Assert.NotNull(Find(root, AppraisalUiController.ItemTextId));
Assert.NotNull(Find(root, AppraisalUiController.ItemScrollbarId));
Assert.NotNull(Find(root, AppraisalUiController.CreaturePanelId));
Assert.NotNull(Find(root, AppraisalUiController.SpellPanelId));
ImportedLayout layout = FixtureLoader.LoadExamination();
Assert.IsType<UiButton>(layout.FindElement(AppraisalUiController.CloseId));
Assert.IsType<UiText>(layout.FindElement(AppraisalUiController.TitleId));
Assert.IsType<UiScrollbar>(
layout.FindElement(AppraisalUiController.ItemScrollbarId));
}
public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases
=> new()
{

View file

@ -131,6 +131,7 @@ public sealed class RetailLayoutFixtureGenerator
"effects_row_2100001B_10000128.json"),
(CharacterController.LayoutId, CharacterController.RootId,
"character_info_2100006E_10000183.json"),
(0x2100006Bu, 0x100005F2u, "examine_2100006B_100005F2.json"),
})
{
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);

File diff suppressed because it is too large Load diff

View file

@ -49,6 +49,50 @@ public sealed class GameEventWiringTests
return (dispatcher, items, combat, spellbook, chat);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void WireAll_IdentifyObjectResponse_ForwardsCompleteResultAndMergesOnlySuccess(
bool success)
{
const uint guid = 0x50000001u;
var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable();
items.UpsertProperties(guid, new PropertyBundle());
AppraiseInfoParser.Parsed? received = null;
GameEventWiring.WireAll(
dispatcher,
items,
new CombatState(),
new Spellbook(),
new ChatLog(),
onAppraisal: appraisal => received = appraisal);
byte[] payload = new byte[24];
BinaryPrimitives.WriteUInt32LittleEndian(payload, guid);
BinaryPrimitives.WriteUInt32LittleEndian(
payload.AsSpan(4),
(uint)AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), success ? 1u : 0u);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(12), 1);
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(14), 16);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 25u);
BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan(20), 42);
GameEventEnvelope? envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.IdentifyObjectResponse, payload));
Assert.NotNull(envelope);
dispatcher.Dispatch(envelope.Value);
Assert.NotNull(received);
Assert.Equal(success, received.Value.Success);
Assert.Equal(42, received.Value.Properties.Ints[25u]);
if (success)
Assert.Equal(42, items.Get(guid)!.Properties.Ints[25u]);
else
Assert.False(items.Get(guid)!.Properties.Ints.ContainsKey(25u));
}
[Fact]
public void WireAll_ChannelBroadcast_RoutesToChatLog()

View file

@ -183,6 +183,19 @@ public sealed class AppraiseInfoParserTests
Assert.Null(AppraiseInfoParser.TryParse(new byte[4]));
}
[Fact]
public void TryParse_TruncatedGatedField_RejectsEntirePacket()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write((uint)AppraiseInfoParser.IdentifyResponseFlags.HookProfile);
bw.Write(1u);
bw.Write(0x3u);
Assert.Null(AppraiseInfoParser.TryParse(ms.ToArray()));
}
// ── Profile blobs ────────────────────────────────────────────────────────
[Fact]
@ -322,4 +335,31 @@ public sealed class AppraiseInfoParserTests
Assert.Equal((ushort)0x00FF, parsed.Value.ArmorEnchantments!.Value.Highlight);
Assert.Equal((ushort)0x0042, parsed.Value.ArmorEnchantments.Value.Color);
}
[Fact]
public void TryParse_HookProfile_PreservesAlignmentForFollowingFields()
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
bw.Write(0x50000001u);
bw.Write((uint)(
AppraiseInfoParser.IdentifyResponseFlags.HookProfile
| AppraiseInfoParser.IdentifyResponseFlags.ArmorEnchantmentBitfield));
bw.Write(1u);
bw.Write(0x0000000Bu);
bw.Write(0x00100000u);
bw.Write(0x00000004u);
bw.Write((ushort)0x1234);
bw.Write((ushort)0x5678);
var parsed = AppraiseInfoParser.TryParse(ms.ToArray());
Assert.NotNull(parsed);
Assert.Equal(
new AppraiseInfoParser.HookProfile(0xBu, 0x00100000u, 4u),
parsed.Value.HookProfile);
Assert.Equal(
((ushort)0x1234, (ushort)0x5678),
parsed.Value.ArmorEnchantments);
}
}