From 44f9ec13d90ab14594e464f74137b715041dac51 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Jul 2026 17:55:16 +0200 Subject: [PATCH] docs(ui): pin retained widget retail oracles --- docs/ISSUES.md | 22 +- .../retail-divergence-register.md | 10 +- ...-07-10-retail-panel-behavior-pseudocode.md | 851 ++++++++++++++ ...0-retail-toolbar-interaction-pseudocode.md | 1020 +++++++++++++++++ ...-retained-widget-foundations-pseudocode.md | 769 +++++++++++++ .../research/retail-ui/00-master-synthesis.md | 6 + docs/research/retail-ui/04-input-events.md | 7 + ...026-07-10-retail-ui-fidelity-completion.md | 126 +- .../2026-06-15-chat-window-redrive-design.md | 7 + ...26-06-22-d2b-container-switching-design.md | 8 + 10 files changed, 2753 insertions(+), 73 deletions(-) create mode 100644 docs/research/2026-07-10-retail-panel-behavior-pseudocode.md create mode 100644 docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md create mode 100644 docs/research/2026-07-10-retained-widget-foundations-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0b9e9e7d..dd37ab9a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1576,22 +1576,22 @@ R5/R6 touches the action list. **Where:** --- -## #196 — Inventory window close never notifies the controller (NoLongerViewingContents skipped) +## #196 — External-container lifecycle and retained window events are missing **Status:** OPEN **Severity:** MEDIUM **Filed:** 2026-07-02 **Component:** ui / net -**Description:** `UiRoot`'s window registry flips `Visible` with no event, and the inventory close button routes to `GameWindow.CloseRetailWindow` (HideWindow + toolbar sync only). `InventoryController` never learns the window closed: `NoLongerViewingContents (0x0195)` is not sent for the viewed side pack, `_openContainer` stays stale, and reopening shows the old bag grid without re-sending Use. +**Description:** `UiRoot`'s window registry flips `Visible` with no lifecycle event, toolbar-button sync is manually pushed at each mutation site, and acdream has no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to `ClientUISystem.groundObject`: replacing or closing an external container sends `0x0195` exactly once. The owned `gmInventoryUI` side-bag path has no corresponding static send. -**Root cause / status:** Review finding (theme T2). Fix direction: add `WindowShown`/`WindowHidden` events to the UiRoot window registry; InventoryController closes out the viewed container on hide; toolbar-button sync subscribes instead of the manual `SyncToolbarWindowButtons()` push at every mutation site. +**Root cause / status:** Review finding (theme T2), re-framed by the 2026-07-10 named-retail trace. Add typed retained-window lifecycle events and subscriber-driven toolbar state. Port a separate external-container owner whose ground-object replacement/close path emits one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace. -**Files:** `src/AcDream.App/UI/UiRoot.cs:541-572`, `src/AcDream.App/UI/Layout/InventoryController.cs:404`, `src/AcDream.App/Rendering/GameWindow.cs` (`CloseRetailWindow`/`SyncToolbarWindowButtons`). +**Files:** `src/AcDream.App/UI/UiRoot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`, `src/AcDream.App/Rendering/GameWindow.cs` (`CloseRetailWindow`/`SyncToolbarWindowButtons`); future external-container controller. -**Research:** `docs/research/2026-07-02-ui-architecture-review.md`. +**Research:** `docs/research/2026-07-02-ui-architecture-review.md`; correction oracle `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. -**Acceptance:** closing the inventory window sends NoLongerViewingContents for the open side pack; reopening shows the main pack; no manual Sync call sites remain. +**Acceptance:** replacing/closing an external container sends `NoLongerViewingContents` exactly once; owned main/side-pack navigation and closing send nothing absent contrary trace evidence; lifecycle subscribers replace manual toolbar sync calls; repeat hiding is idempotent. --- @@ -1638,22 +1638,22 @@ states `0x28` and `0x29`. --- -## #199 — Character raise flow: determine and port authoritative/predicted reconciliation +## #199 — Port the server-authoritative character raise flow **Status:** OPEN **Severity:** MEDIUM **Filed:** 2026-07-02 **Component:** ui / core -**Description:** Item moves reconcile optimistic state through `RecordPending`/`ConfirmMove`/`RollbackMove` (0x0022/0x0023/0x00A0); character raises currently apply optimistically (`CharacterSheetProvider.HandleRaiseRequest` → `ApplyLocalRaise`) with no pending snapshot and no rollback. First trace whether retail predicts or waits for authoritative state. Then either remove prediction or port a faithful pending/confirm/rollback path. Register row AP-73. +**Description:** Character raises currently mutate XP/credits/ranks optimistically (`CharacterSheetProvider.HandleRaiseRequest` → `ApplyLocalRaise`). Named retail does not predict: it permits one request in flight, ghosts the clicked button, and leaves displayed state unchanged until an authoritative quality-change element message supplies the new values. -**Root cause / status:** Review finding (theme T2); the spends now at least go through eventful store APIs (extraction commit). The old issue assumed retail predicts without evidence; Wave 8 requires the named/cdb behavior pin before choosing the reconciliation mechanism. +**Root cause / status:** Review finding (theme T2), resolved oracle question on 2026-07-10. Remove local mutation, add a one-in-flight owner, ghost the requested button, and clear awaiting state from the authoritative quality-change path. The exact rejection cleanup still needs a live ACE trace; do not mask it with a timer/retry. **Files:** `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`), `src/AcDream.Core/Player/LocalPlayerState.cs` (`Apply*Raise`). -**Research:** `docs/research/2026-07-02-ui-architecture-review.md`. +**Research:** `docs/research/2026-07-02-ui-architecture-review.md`; authoritative flow `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. -**Acceptance:** a raise the server rejects (e.g. insufficient XP race) visibly reverts within one echo round-trip; a confirmed raise never flickers. +**Acceptance:** sending a raise does not change local XP/credits/ranks; a second request is blocked while one is awaiting; the matching authoritative update refreshes state and re-enables the button; rejection/relog cleanup follows captured server behavior without retry loops. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 80c50a74..a39d4157 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 100 active rows +## 3. Documented approximation (AP) — 99 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -179,7 +179,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | | AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values | -| AP-73 | **Character raises apply optimistically with no verified retail prediction contract or rollback** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` bumps ranks and debits XP/credits through eventful APIs. Retail's predict-vs-authoritative behavior is unverified (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE normally confirms client-affordable raises and later property echoes overwrite state; Wave 8 must trace retail before choosing prediction or authoritative-only display | A rejected/reordered raise can leave the sheet wrong until a later full refresh | candidate cdb trace: retail raise-button send/echo path | +| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | --- | AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 | @@ -202,7 +202,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-94 | Imported Type-12 text defaults to interactive/selectable behavior; display labels can focus/capture/drag, and vitals synthesize duplicate runtime labels instead of binding `0x100000EB/ED/EF` | `src/AcDream.App/UI/UiText.cs`; `src/AcDream.App/UI/Layout/VitalsController.cs` | Historical widget-generalization default; Wave 1 ports explicit Display/Selectable/Editable roles | Invisible/static text steals input and duplicate labels drift from DAT geometry | `UIElement_Text` property handlers; `gmVitalsUI::PostInit @ 0x004BFCE0` | | AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | | AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance | -| AP-97 | Device timers are registered in `UiRoot` but never pumped or cancelled with subtree removal | `src/AcDream.App/UI/UiRoot.cs` timer registry | No shipped controller depends on timer delivery yet; Wave 1 adds deterministic dispatch/cancellation | Tooltip, repeat, state-delay, or deferred UI actions never fire or can target removed widgets | Keystone Device timer API and retail UI timer call sites | +| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | | AP-98 | Window registry lacks complete lifecycle/focus/persistence ownership; only radar position persists, and chat maximizes its imported content to hardcoded 320 px rather than resizing the outer frame | `src/AcDream.App/UI/UiRoot.cs`; `ChatWindowController.cs`; production mounts in `GameWindow.cs` | Windows remain usable through manual show/hide and local mount recipes; Wave 2 centralizes the contract | Close cleanup is skipped, layouts reset/drift, and chat chrome/content disagree after maximize | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior | | AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` | @@ -211,7 +211,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-105 | Retained chat lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; scrollbar arrow roles are reversed and a second ChatVM loses FPS/position providers | `src/AcDream.App/UI/Layout/ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path and word wrapping work; Waves 2/5 consolidate the remaining state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `ChatInterface` methods | -| AP-106 | Hiding inventory does not send `NoLongerViewingContents` or reset the viewed side container (#196) | `src/AcDream.App/UI/Layout/InventoryController.cs`; window hide path | Reopening is usable for the last viewed container, but session/view lifecycle diverges | Server viewing state and local caption/grid remain stale across close/reopen | `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50` | +| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | AP-107 | Inventory grid/bag clicks bypass active target-mode acquisition while cursor resolution treats occupied cells as targets (#197) | `src/AcDream.App/UI/Layout/InventoryController.cs`; `UiItemSlot.cs` | Main pack/toolbar/paperdoll already opt in at their call sites | A valid-target cursor click selects/opens rather than consuming the target | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | | 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` | @@ -248,7 +248,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | -| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | +| TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | | TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) | diff --git a/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md b/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md new file mode 100644 index 00000000..8b3ee1c1 --- /dev/null +++ b/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md @@ -0,0 +1,851 @@ +# Retail panel behavior oracle — vitals, chat, inventory, paperdoll, character + +Date: 2026-07-10 +Scope: Wave 0 static research for the retail UI fidelity completion plan. +Status: implementation oracle; no runtime code is changed by this note. + +## Source order and confidence + +The Sept 2013 named retail client is the behavioral authority: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- `docs/research/named-retail/acclient.h` +- `docs/research/named-retail/symbols.json` + +The layout/DAT discoveries already recorded under `docs/research/` are used for +element IDs, layout IDs, and imported media. Existing ACE and holtburger +cross-references in the inventory deep dive are interpretation aids for the +wire contract; the retail client still wins when they differ. + +Binary Ninja occasionally assigns a neighboring multiple-inheritance field +name to a value. This note therefore names fields by the behavior proved by +their setters/callers and records such cases as semantic pseudocode rather +than copying a suspect generated field name. + +### Corrections to assumptions in earlier UI notes + +1. **The named client does not prove a tab strip inside the main chat + window.** It proves one main chat interface plus separately configured + floaty chat windows, each with a window ID and a 64-bit text-type filter. + The `General / Combat / Chat / 1...` strip in + `docs/research/retail-ui/05-panels.md` was explicitly an inference and must + not be implemented as retail fact. Live retail visual confirmation is + required before adding a tab skin. +2. **`NoLongerViewingContents` is an external ground-object lifetime event, + not an owned-bag navigation event.** Retail centralizes the send in + `ClientUISystem::SetGroundObject(old, new, notifyServer)`. Main-inventory + and owned side-bag navigation use the inventory item-list notices instead. +3. **Stat raising is server-authoritative.** Retail serializes one request, + ghosts the raise control, and waits for a quality-change message. It does + not predict the local rank, XP, credits, or cost and then roll them back. + +## 1. Vitals detail mode + +### Retail anchors + +| Behavior | Named symbol | +|---|---| +| Bind imported elements | `gmVitalsUI::PostInit @ 0x004BFCE0` | +| Toggle detail state | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00` | +| Refresh meters and text | `gmVitalsUI::Update @ 0x004BFE30` | + +Layout: `0x2100006C`. + +| Meaning | Element/state ID | +|---|---:| +| Health meter | `0x100000E6` | +| Health value label | `0x100000EB` | +| Stamina meter | `0x100000EC` | +| Stamina value label | `0x100000ED` | +| Mana meter | `0x100000EE` | +| Mana value label | `0x100000EF` | +| Root `HideDetail` state | `0x10000006` | +| Root `ShowDetail` state | `0x10000007` | +| Meter fill/value property | `0x69` | + +The imported `ShowDetail` layout state selects these overlay media: + +| Vital | Back | Front | +|---|---:|---:| +| Health | `0x06007490` | `0x06007491` | +| Stamina | `0x06007492` | `0x06007493` | +| Mana | `0x06007494` | `0x06007495` | + +### Pseudocode + +```text +PostInit(): + healthMeter = child(0x100000E6) + healthLabel = child(0x100000EB) + staminaMeter = child(0x100000EC) + staminaLabel = child(0x100000ED) + manaMeter = child(0x100000EE) + manaLabel = child(0x100000EF) + +OnElementMessage(message): + if message.id == 0x1C and message.pointerAction in { LEFT_CLICK=7, + DOUBLE_CLICK=10 }: + nextState = ShowDetail if root.state == HideDetail else HideDetail + root.setState(nextState) + return base.OnElementMessage(message) + +Update(): + updateVital(healthMeter, healthLabel, currentSecondAttribute=2, max=1) + updateVital(staminaMeter, staminaLabel, currentSecondAttribute=4, max=3) + updateVital(manaMeter, manaLabel, currentSecondAttribute=6, max=5) + +updateVital(meter, label, currentProperty, maximumProperty): + current = player.attribute2nd(currentProperty) + maximum = player.attribute2nd(maximumProperty) + meter.property[0x69] = current / maximum + label.text = formatCurrentAndMaximum(current, maximum) +``` + +Implementation consequence: bind and update the imported label elements. +Creating new text children over the meters is not retail-equivalent and loses +the DAT state-driven geometry/visibility. + +## 2. Chat window + +### Layout and elements + +Main chat layout: `0x21000006`. + +| Meaning | ID | +|---|---:| +| Root field | `0x1000000E` | +| Resize bar | `0x1000000F` | +| Transcript panel | `0x10000010` | +| Transcript/log | `0x10000011` | +| Scrollbar track | `0x10000012` | +| Input bar | `0x10000013` | +| Talk-focus menu | `0x10000014` | +| Chat target text | `0x10000015` | +| Editable input | `0x10000016` | +| Send button | `0x10000019` | +| Maximize/restore button | `0x1000046F` | +| New non-visible text indicator | `0x1000048C` | + +Retail option/property IDs named in the client are: + +| Meaning | ID | +|---|---:| +| Talk-focus enum on a menu item | `0x1000000B` | +| Chat window ID | `0x1000007E` | +| 64-bit text-type filter | `0x1000007F` | +| Default/inactive opacity | `0x10000080` | +| Active/focused opacity | `0x10000081` | +| Maximum height | `0x3C` | +| Maximum width | `0x3D` | +| Minimum height | `0x3E` | +| Minimum width | `0x3F` | +| Maximize button state | `0x10000047` | +| Restore/minimized button state | `0x10000048` | + +### Maximize, restore, and resize + +Retail anchors: + +- `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50` +- `gmMainChatUI::ResizeTo @ 0x004CD0A0` +- `ChatInterface::ResizeTo @ 0x004F39E0` +- `UIElement::ResizeTo @ 0x00463C30` + +```text +HandleMaximizeButton(): + parentHeight = root.parent.height + minHeight = root.property[0x3E] + maxHeight = root.property[0x3C] + + if maximized: + restoredHeight = clamp(savedHeight, minHeight, maxHeight) + restoredY = clamp(savedY, 0, parentHeight - minHeight) + maximized = false + maximizeButton.state = 0x10000048 + root.resize(root.width, restoredHeight) + root.move(root.x, restoredY) + return + + savedY = root.y + savedHeight = root.height + + expansion = parentHeight / 2 + targetHeight = root.height + expansion + + if expanding downward would cross the parent bottom, + or the window is already in the lower half: + targetY = max(0, root.y - expansion) + else: + targetY = root.y + + targetHeight = min(targetHeight, parentHeight - targetY) + targetHeight = clamp(targetHeight, minHeight, maxHeight) + + maximized = true + maximizeButton.state = 0x10000047 + root.resize(root.width, targetHeight) + root.move(root.x, targetY) + +ResizeTo(width, height): + wasAtTranscriptEnd = transcript.isAtVerticalEnd() + base.resizeTo(width, height) // base enforces 0x3C..0x3F limits + resize the bound transcript/input panel, not a hard-coded synthetic box + if wasAtTranscriptEnd: + transcript.scrollToFinalGlyph() +``` + +The saved position and size are part of `gmMainChatUI` (`m_OldY`, +`m_OldHeight`, `m_Maximized` in `acclient.h`). A fixed expanded height is not +retail behavior. + +### Opacity and input activation + +Retail anchors: + +- `ChatInterface::ChatInterface @ 0x004F4550` +- `ChatInterface::OnSetAttribute @ 0x004F3F60` +- `ChatInterface::SetDefaultOpacity @ 0x004F3BC0` +- `ChatInterface::SetActiveOpacity @ 0x004F3C40` +- `ChatInterface::ListenToGlobalMessage @ 0x004F3840` +- `ChatInterface::ActivateChatEntry @ 0x004F2F90` +- `ChatInterface::DeactivateChatEntry @ 0x004F2FC0` +- `ChatInterface::ToggleChatEntry @ 0x004F3B30` +- `ChatInterface::IsTextEntryFocused @ 0x004F30A0` + +Defaults are 0.5 inactive, 1.0 active, and 0.5 current. + +```text +OnAttributeChanged(id, value): + if id == 0x10000080: setDefaultOpacity(value) + if id == 0x10000081: setActiveOpacity(value) + +OnChatFocusStateChanged(): + target = activeOpacity if input is focused or window is forced active + else defaultOpacity + registerForGlobalTicks() + +OnGlobalTick(): + step = abs(activeOpacity - defaultOpacity) * 0.05 + currentOpacity = moveToward(currentOpacity, target, step) + enclosingWindow.alpha = currentOpacity + if currentOpacity == target: + unregisterFromGlobalTicks() + +ActivateChatEntry(): + input.activate() + input.takeFocus() + sendNotice(ToggleChatEntry, true) + +DeactivateChatEntry(): + input.relinquishFocus() + input.deactivate() + sendNotice(ToggleChatEntry, false) + +IsTextEntryFocused(): + return activeRoot == thisChatRoot and focusedElement is input-or-descendant +``` + +### Talk focus and squelch + +Retail anchors: + +- `gmMainChatUI::InitTalkFocusMenu @ 0x004CDC50` +- `gmMainChatUI::GetTalkFocusMenuItem @ 0x004CD370` +- `gmMainChatUI::ResetAllTalkFocusMenuButtons @ 0x004CD4A0` +- `gmMainChatUI::HandleSelection @ 0x004CD540` +- `gmMainChatUI::EnableSelection @ 0x004CE0A0` +- `gmMainChatUI::ToggleSquelchOnCurrentSpeakableTarget @ 0x004CD230` +- `gmMainChatUI::ListenToElementMessage @ 0x004CDA80` +- `gmMainChatUI::PostInit @ 0x004CE130` + +The menu is flushed and rebuilt at runtime. Its first item is the squelch +toggle. The 13 talk-focus entries store these enum values, in visual order: + +```text +[5, 2, 4, 1, 6, 3, 7, 8, 9, 10, 11, 12, 13] +``` + +```text +InitTalkFocusMenu(): + menu = child(0x10000014) + menu.flush() + squelchItem = menu.addRuntimeItem(...) + for focus in [5,2,4,1,6,3,7,8,9,10,11,12,13]: + item = menu.addRuntimeItem(stringTableTextFor(focus)) + item.enumProperty[0x1000000B] = focus + +ResetTalkFocusButtons(): + for each focus item: + item.state = ENABLED(1) if communication.isTalkFocusEnabled(focus) + else GHOSTED(13) + // Olthoi mode permits only focus 13 and focus 1. + select item whose 0x1000000B equals communication.currentTalkFocus + +OnTalkFocusMenuSelection(item): + menu.clearSelectedItem() + if item == squelchItem: + ToggleSquelchOnCurrentSpeakableTarget() + else: + communication.setTalkFocus(item.enumProperty[0x1000000B]) + ResetTalkFocusButtons() + +ToggleSquelchOnCurrentSpeakableTarget(): + target = communication.lastSpeakableTarget + objectAndName = resolve(target) + currentlySquelched = communication.isSquelched(target, objectAndName.name, 1) + squelchItem.state = ENABLED(1) + send ModifyCharacterSquelch( + add = not currentlySquelched, + targetId = target, + targetName = objectAndName.name, + scope = 1) +``` + +Exact localized menu labels are table-driven and are not legible enough in +the pseudo-C to reproduce verbatim. Resolve them from the retail string table +or capture the menu in live retail; do not invent labels. + +### Chat windows and the unproven “tabs” + +`ChatInterface::PostInit @ 0x004F3DD0` reads window ID `0x1000007E`, +text filter `0x1000007F`, and binds transcript `0x10000011`, input +`0x10000016`, and the non-visible indicator `0x1000048C`. Static default filter +values include: + +| Window ID | Default 64-bit filter | +|---:|---:| +| 1 or 8 | `0x0` for the non-visible indicator mask path | +| 2 | `0x101C` | +| 3 | `0x40C00` | +| 4 | `0x80000` | +| 5 | `0x78000000` | + +`PlayerModule` persists a filter per window and the client has +`gmFloatyChatUI`/`gmFloatyMainChatUI` types. That is evidence for multiple +configurable windows, not evidence for tabs inside `gmMainChatUI`. + +## 3. Inventory click targeting and view teardown + +### Target-mode precedence + +Retail anchors: + +- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` +- `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` +- `UIElement_ItemList::ItemList_SetParentContainer @ 0x004E49D0` +- `UIElement_ItemList::ItemList_OpenContainer @ 0x004E4AE0` + +```text +OnItemListPointerMessage(message): + item = uiItemUnderPointer(message) + + if message.action == LEFT_CLICK(7): + if ClientUISystem.targetMode != NONE: + HandleTargetedUseLeftClick(item) + return CONSUMED // before select/open navigation + + setUiSelected(item) + SetSelectedObject(item.itemId, false) + + if this list is container navigation: + open/select the item's parent-or-child list according to list role + return + + if message.action == DOUBLE_CLICK(10): + if vendor/salvage rules did not consume it: + if this is a container-list, require item == current external object + ItemHolder.UseObject(item.itemId) + return + + if message.action == EXAMINE_CLICK(8): + select item + ExamineSpell(item.spellId) if this is a spell shortcut + else ExamineObject(item.itemId) + +HandleTargetedUseLeftClick(item): + targetMode = ClientUISystem.targetMode + if item represents a spell: + ClientUISystem.ExecuteTargetModeForSpell(item.spellId, targetMode) + else: + ClientUISystem.ExecuteTargetModeForItem(item.itemId, targetMode) +``` + +The critical rule is ordering: a target-mode click is consumed before ordinary +selection, container opening, or drag behavior, including an invalid target +attempt. + +### Owned inventory versus external ground views + +Owned inventory anchors: + +- `gmInventoryUI::RecvNotice_SetDisplayInventory @ 0x004A6750` +- `gmInventoryUI::RecvNotice_OpenContainedContainer @ 0x004A6A80` + +External view anchors: + +- `gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0` +- `gmExternalContainerUI::SetGroundObject @ 0x004CBBD0` +- `gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0` +- `gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50` +- `gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0` +- `ClientUISystem::SetGroundObject @ 0x00564510` +- `ClientUISystem::OnViewContents @ 0x00565870` +- `ClientUISystem::CleanUpGameUI @ 0x005648A0` +- `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50` + +Wire contract, cross-confirmed by the existing holtburger reference in the +inventory deep dive: + +```text +opcode: 0x0195 NoLongerViewingContents +payload: u32 containerGuid +``` + +```text +ShowMainOwnedInventory(): + topList.flush() + topList.add(playerId) + childList.setParentContainer(playerId) + topList.openContainer(playerId) + paperdoll.remake() + +OpenOwnedContainedContainer(containerId): + require object is owned by player and has an allowed container type + make inventory visible + if containerId == playerId: + use top/main list + else: + use owned side/container list + // This path does not make containerId ClientUISystem.groundObject. + +OnExternalViewContentsResponse(containerId, contentProfile): + apply authoritative content profile to the external object + if containerId == requestedGroundObject: + SetGroundObject(containerId) + publish SetGroundObject notice to gmExternalContainerUI + +ExternalPanel.SetGroundObject(containerId): + unregister range handler for old external object + flush top list + add containerId + bind child lists and open container + if containerId == 0: + hide panel + else: + register range handler and show panel + +ExternalPanel.OnCloseButton(): + setVisible(false) + +ExternalPanel.OnVisibilityChanged(false): + CloseCurrentContainer() + +ExternalPanel.CloseCurrentContainer(): + if externalObjectId != 0: + ItemHolder.UseObject(externalObjectId) // retail close/use handshake + unregister range handler + externalObjectId = 0 + flush and detach lists + +ClientUISystem.SetGroundObject(newId, notifyServer): + if oldId == newId: return + close vendor if needed + if oldId != 0: + queue old contents for destruction + publish SetGroundObject(0) + if notifyServer: + send NoLongerViewingContents(oldId) exactly once + clear selected child of old object when applicable + groundObject = newId + requestedGroundObject = newId + mark corpse-open state if applicable +``` + +`SetGroundObject(0, true)` is also used for session cleanup, vendor opening, +the viewed object moving, range/error conditions, and other external-view +termination. This centralized old-ID comparison provides the one-shot guard; +adding independent sends in several UI close callbacks would duplicate it. + +Static code does **not** establish that hiding the main owned-inventory window +after navigating an owned side bag sends `0x0195`; the object is not the +`ClientUISystem.groundObject` on that path. Treat such a send as a divergence +unless a packet trace proves otherwise. + +## 4. Paperdoll + +Paperdoll layout: `0x21000024`. + +### Slot mapping + +Retail anchor: `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`. + +| Element | Equip/location mask | Side/meaning | +|---:|---:|---| +| `0x100001DA` | `0x00008000` | Neck | +| `0x100001DB` | `0x00010000` | Left wrist | +| `0x100001DC` | `0x00040000` | Left ring | +| `0x100001DD` | `0x00020000` | Right wrist | +| `0x100001DE` | `0x00080000` | Right ring | +| `0x100001DF` | `0x03500000` | Weapon composite | +| `0x100001E0` | `0x00800000` | Ammunition | +| `0x100001E1` | `0x00200000` | Shield/offhand | +| `0x100001E2` | `0x00000002` | Shirt | +| `0x100001E3` | `0x00000040` | Pants | +| `0x1000058E` | `0x04000000` | Trinket | +| `0x100005E9` | `0x08000000` | Cloak | +| `0x10000595` | `0x10000000` | Aetheria/sigil 1 | +| `0x10000596` | `0x20000000` | Aetheria/sigil 2 | +| `0x10000597` | `0x40000000` | Aetheria/sigil 3 | +| `0x100005AB` | `0x00000001` | Head armor | +| `0x100005AC` | `0x00000200` | Chest armor | +| `0x100005AD` | `0x00000400` | Abdomen armor | +| `0x100005AE` | `0x00000800` | Upper arm armor | +| `0x100005AF` | `0x00001000` | Lower arm armor | +| `0x100005B0` | `0x00000020` | Hand armor | +| `0x100005B1` | `0x00002000` | Upper leg armor | +| `0x100005B2` | `0x00004000` | Lower leg armor | +| `0x100005B3` | `0x00000100` | Foot armor | + +The named pseudo-C renders the ammunition constant poorly in one expression; +`0x00800000` is the consistent EquipMask/source value. Preserve a decomp cite +and conformance test when implementing that branch. + +### Upper-item priority and body hit testing + +Retail anchors: + +- `InventoryPlacement::DetermineHigherPriority @ 0x004A44B0` +- `gmPaperDollUI::GetUpperInvObj @ 0x004A4750` +- `gmPaperDollUI::CreateClickMap @ 0x004A4850` +- `gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920` +- `gmPaperDollUI::ListenToElementMessage @ 0x004A5C30` +- `gmPaperDollUI::BeginPartSelectionLighting @ 0x004A4C50` +- `gmPaperDollUI::ApplyPartSelectionLighting @ 0x004A3CA0` +- `gmPaperDollUI::UpdatePartSelectionLighting @ 0x004A4360` + +```text +DetermineHigherPriority(a, b, bodyLocationMask): + aLoc = a.location & bodyLocationMask + bLoc = b.location & bodyLocationMask + + aPriority = a.placementPriority + bPriority = b.placementPriority + + if aPriority == 0 and aLoc overlaps armor range 0x200..0x4000: + aPriority = 0x7F + if bPriority == 0 and bLoc overlaps armor range 0x200..0x4000: + bPriority = 0x7F + + return b if bPriority > aPriority else a // stable: existing wins ties + +GetUpperInvObj(bodyLocationMask): + if invalid mask/player/list: return 0 + winner = none + for placement in player.inventoryPlacements in list order: + if placement.location overlaps bodyLocationMask: + winner = placement if winner is none + else DetermineHigherPriority(winner, placement, + bodyLocationMask) + if winner exists: return winner.itemId + return playerId // retail body hit fallback when a placement list exists +``` + +`CreateClickMap` loads enum DID `0x1000000C` in category 7. The sampled body +color maps to these masks: + +| Click-map value | Body mask | +|---:|---:| +| Head | `0x00000001` | +| Chest | `0x00000202` (shirt + chest) | +| Abdomen | `0x00000404` | +| Upper arm | `0x00000808` | +| Lower arm | `0x00001010` | +| Upper leg | `0x00002040` | +| Lower leg | `0x00004080` | +| Hand | `0x00000020` | +| Foot | `0x00000100` | + +The drag mask is `0x100001D6`. Left-click selects the upper equipped object +when not targeting. In target mode, the body click deliberately executes the +target mode against the **player ID**, not the visually upper item. Examine +and drag use the upper item. Selection lighting pulses matching body parts at +0.2-second intervals; player-ID selection uses mask `0x7FFFFFFF`. + +### Aetheria visibility + +Retail anchors: + +- `gmPaperDollUI::PostInit @ 0x004A5360` +- `gmPaperDollUI::UpdateAetheria @ 0x004A3E50` +- `gmPaperDollUI::RecvNotice_PlayerDescReceived @ 0x004A43E0` +- `gmPaperDollUI::OnQualityChanged @ 0x004A4490` + +Property `0x142` is the Aetheria bitfield. + +```text +PostInit(): + aetheria[0..2] = children(0x10000595, 0x10000596, 0x10000597) + hide all three + observe player int quality 0x142 + +UpdateAetheria(): + bits = player.intQuality(0x142) + aetheria[0].visible = (bits & 1) != 0 + aetheria[1].visible = (bits & 2) != 0 + aetheria[2].visible = (bits & 4) != 0 +``` + +### Wield/drop legality + +Retail anchors: + +- `gmPaperDollUI::HandlePaperDollDragOver @ 0x004A3A70` +- `gmPaperDollUI::AcceptDragObject @ 0x004A3B10` +- `gmPaperDollUI::OnItemListDragOver @ 0x004A4270` +- `gmPaperDollUI::AcceptPaperDollDragObject @ 0x004A4A70` +- `gmPaperDollUI::HandleDropRelease @ 0x004A4D80` +- `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60` + +```text +OnSlotDragOver(item, slotMask): + accept = item.validLocations overlaps slotMask + and PlayerSystem.AutoWieldIsLegal(item, silent=true) + set overlay accept/reject state + +AcceptDragObject(item, slotMask, side): + if slotMask == SHIELD(0x00200000) + and item.validLocations includes MELEE_WEAPON(0x00100000): + side = LEFT // retail dual-wield/offhand special case + + if not (item.validLocations overlaps slotMask): reject + + if slotMask overlaps wearable mask 0x080001FF: + PlayerSystem.AutoWear(item) + else: + PlayerSystem.AutoWield(item, side) + +AcceptWholeDollDrop(item): + require item.validLocations overlaps wearable body mask 0x08007FFF + require AutoWearIsLegal(item) + AutoWear(item) +``` + +`AutoWieldIsLegal` checks nonzero valid locations, missile/ammunition +compatibility, shield versus current weapon, and held-item restrictions while +in combat. One mask expression is corrupt in the named pseudo-C. Resolve that +exact branch from the older Ghidra chunk or a live breakpoint before porting +it; do not infer the missing bit from the error string alone. + +### Race/body viewport variants + +Retail anchors: + +- `gmPaperDollUI::PostInit @ 0x004A5360` +- `gmPaperDollUI::UpdateForRace @ 0x004A3ED0` + +Viewport `0x100001D5`, drag mask `0x100001D6`, drag overlay `0x1000046D`, +and slot-mode checkbox `0x100005BE` are bound in `PostInit`. The default camera +is approximately `(0.12, -2.4, 0.88)` with heading `191.37 degrees`; the +default animation is enum DID `0x10000005`. + +Heritage/body property `0xBC` selects these static variants: + +| Heritage value | Name | Camera position | Animation override | +|---:|---|---|---:| +| 6 | Gearknight | `(0.12, -3.0, 0.88)` | none | +| 7 | Tumerok | `(0.12, -3.0, 0.88)` | none | +| 8 | Lugian | `(0.12, -3.4, 1.0)` | none | +| 9 | Empyrean | `(0.12, -3.4, 0.88)` | none | +| 10 | Penumbraen | no explicit branch | none | +| 11 | Undead | no explicit branch | none | +| 12 | Olthoi | `(0.12, -3.4, 0.88)` | enum DID `0x10000011`, category 7 | +| 13 | Olthoi variant | `(0.12, -3.4, 0.88)` | enum DID `0x10000013`, category 7 | + +The look-at point for explicit variants is `(0, 0, 0)`. Values 10 and 11 have +no explicit case in this function; preserve default/previous setup rather than +inventing a camera. + +## 5. Character titles, luminance, and stat raises + +Character layout: `0x2100002E`; Titles sublayout: `0x2100005E`; Titles page +element: `0x10000539`. + +### Titles + +Retail anchors: + +- `gmCharacterTitleUI::PostInit @ 0x0049A610` +- `gmCharacterTitleUI::UpdateButtons @ 0x0049A500` +- `gmCharacterTitleUI::ListenToElementMessage @ 0x0049A6D0` +- `gmCharacterTitleUI::FindSortedInsertPosition @ 0x0049A760` +- `gmCharacterTitleUI::AddTitleToList @ 0x0049A840` +- `gmCharacterTitleUI::Refresh @ 0x0049ABC0` +- `gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable @ 0x0049AD30` +- `gmCharacterTitleUI::RecvNotice_SetDisplayCharacterTitle @ 0x0049AD50` + +| Meaning | ID | +|---|---:| +| Displayed title text | `0x1000052F` | +| Set/display button | `0x10000535` | +| Title listbox | `0x10000532` | +| Runtime row title text child | `0x10000537` | +| Runtime row title-ID enum property | `0x1000008E` | + +```text +Refresh(): + displayedTitle.text = titleTable.resolve(displayedTitleId) or "Unknown" + titleList.flush() + titleList.clearSelection() + for titleId in ownedTitleIds: + AddTitleToList(titleId) + UpdateButtons() + +AddTitleToList(titleId): + titleText = titleTable.resolve(titleId) + insertIndex = first row whose text compares after titleText using wcscmp + row = createRuntimeTitleRow() + row.child(0x10000537).text = titleText + row.enumProperty[0x1000008E] = titleId + titleList.insert(insertIndex, row) + +UpdateButtons(): + selectedId = selectedRow.enumProperty[0x1000008E] + displayButton.state = ENABLED(1) if selectedId exists + and selectedId != displayedTitleId + else GHOSTED(13) + +OnDisplayButtonClick(): + send CM_Social.Event_SetDisplayCharacterTitle(selectedId) + +OnTitleTableNotice(table): + copy table + Refresh() + +OnDisplayedTitleNotice(titleId): + displayedTitleId = titleId + ensure titleId is present in the owned list + Refresh() +``` + +`gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770` resolves the current +title through `CharacterTitleTable` and appends it to the gender/heritage +header. A synthetic inert titles page is therefore incomplete behavior. + +### Luminance + +Retail anchors: + +- `gmStatManagementUI::PostInit @ 0x004EFD90` +- `gmStatManagementUI::UpdateExperience @ 0x004F0A70` + +| Meaning | ID/property | +|---|---:| +| Luminance label element | `0x100005C5` | +| Luminance value element | `0x100005C6` | +| Total XP text | `0x10000235` | +| XP meter | `0x10000236` | +| Next XP text | `0x10000238` | +| XP list | `0x1000023D` | +| Level int quality | `0x19` | +| TotalExperience int64 quality | `1` | +| AvailableLuminance int64 quality | `6` | +| MaximumLuminance int64 quality | `7` | +| Meter float property | `0x69` | + +```text +UpdateExperience(): + level = player.intQuality(0x19) + totalXp = player.int64Quality(1) + availableLuminance = player.int64Quality(6) + maximumLuminance = player.int64Quality(7) + + if level < 200 or maximumLuminance == 0: + luminanceLabel.text = "" + luminanceValue.text = "" + else: + luminanceLabel.text = localizedLuminanceLabel + luminanceValue.text = formatXp(availableLuminance) + + localizedSeparator + + formatXp(maximumLuminance) + + update total/next XP strings + xpMeter.property[0x69] = fractionalProgressWithinCurrentLevel +``` + +The exact localized label/separator is obscured in the generated pseudo-C. +Read it from the retail string table or capture the rendered panel before +claiming verbatim text. + +### Server-authoritative raises + +Retail anchors: + +- `gmStatManagementUI::gmStatManagementUI @ 0x004F03F0` +- `gmAttributeUI::RaiseSelection @ 0x0049D020` +- `gmAttributeUI::Raise10 @ 0x0049D150` +- `gmSkillUI::RaiseSelection @ 0x0049C8C0` +- `gmSkillUI::Raise10 @ 0x0049B760` +- `InfoRegion::OnQualityChanged @ 0x004F0EB0` +- `gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0` + +```text +OnRaiseClick(amount): + if awaitingRaise: return + require selected row and player description + exactCost = selectedRow.computeRetailCost(amount) + if local preconditions fail: return + + awaitingRaise = true + send the appropriate TrainAttribute/TrainSkill event + clickedButton.state = GHOSTED(13) + // Do not mutate rank, advancement class, XP, credits, or costs locally. + +InfoRegion.OnAuthoritativeQualityChanged(statType, statId): + refresh row from player qualities + broadcast element message 0x10000004(statType, statId) + +StatManagement.OnElementMessage(0x10000004, statType, statId): + awaitingRaise = false + if changed stat is the selected row: + refresh footer and controls from authoritative qualities +``` + +This is direct static evidence against optimistic prediction. The current +implementation should remove its local `ApplyLocalRaise` mutation rather than +building a rollback ledger around behavior retail does not have. + +The static class does not make the rejection-only path fully obvious when the +server sends no changed quality. Verify capped/invalid requests with a packet +trace or breakpoint before choosing how to release `awaitingRaise` on explicit +server failure. Retail still permits only one request in flight. + +## 6. Items requiring live retail, cdb, or an older Ghidra chunk + +These uncertainties are intentionally not guessed: + +1. **Chat surface:** visually confirm whether this client build exposes its + multiple chat filters as separate floaty windows, tabs supplied by another + layout layer, or both. The named `gmMainChatUI` path alone has no tab logic. +2. **Chat strings:** capture the exact localized talk-focus labels, target + label, and squelch wording from the string table/live menu. +3. **Chat geometry:** capture a maximize/restore sequence near the top and + bottom edges to conformance-test the decompiler's signed half-height + arithmetic and DAT min/max constraints. +4. **Owned bag close packet:** trace packets while hiding inventory on an + owned side bag. Static evidence says this is not an external + `NoLongerViewingContents` event. +5. **External close handshake:** capture the order between + `gmExternalContainerUI::CloseCurrentContainer`, `ItemHolder::UseObject`, the + server response, and `ClientUISystem::SetGroundObject(0, true)`. +6. **Paperdoll legality:** recover the corrupt `AutoWieldIsLegal` mask branch + from the older Ghidra chunk and verify missile/ammo, shield, dual-wield, and + in-combat denials with golden cases. +7. **Paperdoll visuals:** visually verify race camera framing, Olthoi + animation enum results, click-map alignment, and the 0.2-second part pulse. +8. **Luminance text:** resolve exact localized label/value formatting from + the string table or rendered client. +9. **Raise rejection:** trace a capped/invalid raise that produces no quality + change to identify the exact event that clears `m_bAwaitingRaise`. + +Everything else above is statically pinned strongly enough to drive the first +implementation and conformance-test wave. diff --git a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md new file mode 100644 index 00000000..ff16f898 --- /dev/null +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -0,0 +1,1020 @@ +# Retail toolbar, item interaction, targeting, and cursor pseudocode + +**Date:** 2026-07-10 + +**Build oracle:** Sept. 2013 EoR `acclient.exe` v11.4186 with matching PDB + +**Purpose:** Wave 0 oracle for the retained retail UI fidelity plan. This note +records behavior only; it does not prescribe App/Core class layout and changes no +runtime code. + +## Sources and confidence + +Primary retail sources: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` +- `docs/research/named-retail/acclient.h` +- `docs/research/named-retail/symbols.json` + +Client/server cross-checks: + +- ACE: + - `Source/ACE.Server/Network/Structure/Shortcut.cs` + - `Source/ACE.Server/Network/GameAction/Actions/GameActionAddShortcut.cs` + - `Source/ACE.Server/Network/GameAction/Actions/GameActionUseItem.cs` + - `Source/ACE.Server/Network/GameAction/Actions/GameActionUseWithTarget.cs` + - `Source/ACE.Server/Network/GameEvent/Events/GameEventPlayerDescription.cs` + - `Source/ACE.Server/WorldObjects/Player_Character.cs` +- holtburger: + - `crates/holtburger-protocol/src/messages/player/shortcuts.rs` + - `crates/holtburger-protocol/src/messages/player/events.rs` + - `crates/holtburger-protocol/src/messages/object/types.rs` + - `crates/holtburger-protocol/src/messages/game_action.rs` + +The reference repositories are present in the developer checkout rather than this +worktree's intentionally sparse `references/` directory. Retail named symbols are +the behavioral oracle; ACE and holtburger are wire-shape checks. + +Confidence labels used below: + +- **Exact:** control flow and fields are legible in the named pseudo-C/header. +- **Exact wire:** retail, ACE, and holtburger agree on byte count and order. +- **Semantic name uncertain:** numeric behavior is exact, but the decompiler lost + a source enum/property name. +- **Unresolved:** the named body is ambiguous enough that implementation must pin + it with Ghidra/cdb or a DAT/capture fixture before porting. + +--- + +## 1. Symbol index + +### Toolbar and selected-object strip + +| Symbol | Address | Role | +|---|---:|---| +| `gmToolbarUI::RecvNotice_UpdateItemMana` | `0x004BD0C0` | Selected-item mana visibility/value | +| `gmToolbarUI::RecvNotice_UpdateObjectHealth` | `0x004BD110` | Selected-object health visibility/value | +| `gmToolbarUI::OnItemListDragOver` | `0x004BD150` | Shortcut drop accept state | +| `gmToolbarUI::IsShortcutEligible` | `0x004BD1D0` | Item eligibility for a shortcut | +| `gmToolbarUI::RecvNotice_SplitStack` | `0x004BD2A0` | Focus/select stack entry text | +| `gmToolbarUI::RecvNotice_SetPanelVisibility` | `0x004BD300` | Open/closed panel-button state | +| `gmToolbarUI::UseShortcut` | `0x004BD350` | Target/use/select shortcut dispatch | +| `gmToolbarUI::FlushShortcuts` | `0x004BD420` | Clear all 18 visual lists | +| `gmToolbarUI::RemoveShortcut` | `0x004BD450` | Remove by object id | +| `gmToolbarUI::RemoveShortcutInSlotNum` | `0x004BD4F0` | Remove by slot and return object id | +| `gmToolbarUI::GetFirstEmptyShortcutToTheRightOf` | `0x004BD560` | Cyclic empty-slot search | +| `gmToolbarUI::IsShortcutSlotAvailable` | `0x004BD5E0` | Bounds + empty check | +| `gmToolbarUI::RecvNotice_SetCombatMode` | `0x004BD610` | Shortcut-number/combat indicator refresh | +| `gmToolbarUI::RecvNotice_ItemListBeginDrag` | `0x004BD930` | Remove-on-lift and remember source slot | +| `gmToolbarUI::AddShortcut` | `0x004BD9A0` | Visual add plus optional wire/local model update | +| `gmToolbarUI::CreateShortcutToItem` | `0x004BDAC0` | Eligibility/ownership/slot orchestration | +| `gmToolbarUI::ListenToGlobalMessage` | `0x004BE4E0` | Use/select/create shortcut input actions | +| `gmToolbarUI::HandleDropRelease` | `0x004BE7C0` | Inventory/toolbar/inventory-button drop branches | +| `gmToolbarUI::RecvNotice_FullMergingItem` | `0x004BE9B0` | Rekey a shortcut after a full stack merge | +| `gmToolbarUI::PostInit` | `0x004BEA80` | Bind buttons, meters, stack controls, and slots | +| `gmToolbarUI::ListenToElementMessage` | `0x004BEE90` | Stack editing, drop, combat/use/examine buttons | +| `gmToolbarUI::HandleSelectionChanged` | `0x004BF380` | Name/health/mana/stack/use-button state | +| `gmToolbarUI::UpdateFromPlayerDesc` | `0x004BF810` | Restore object shortcuts from player module | + +### Item interaction and target mode + +| Symbol | Address | Role | +|---|---:|---| +| `UIElement_ItemList::HandleTargetedUseLeftClick` | `0x004E24D0` | Item/spell target-mode dispatch | +| `UIElement_ItemList::ListenToElementMessage` | `0x004E4D50` | Target-first item-list click ordering | +| `CPlayerSystem::OnAction` | `0x00561890` | World selection/target acquisition ordering | +| `CPlayerSystem::UsingItem` | `0x00562F70` | Execute `DetermineUseResult` and container paths | +| `ClientUISystem::SetTargetMode` | `0x00564940` | Input-map and cursor lifecycle | +| `ClientUISystem::ExecuteTargetModeForItem` | `0x00564B30` | Use/examine/use-with-target dispatch | +| `ItemHolder::TargetCompatibleWithObject` | `0x00587520` | Full compatibility and user-message path | +| `ItemHolder::IsTargetCompatibleWithTargetingObject` | `0x00588070` | Cursor-compatible, no-message predicate | +| `ItemHolder::DetermineUseResult` | `0x00588460` | Primary-use classification | +| `ItemHolder::AttemptPlaceIn3D` | `0x00588600` | Drag/drop placement decision tree | +| `ItemHolder::UseObject` | `0x00588A80` | Primary use orchestration | +| `ItemHolder::TargetAcquired` | `0x00588EF0` | Complete targeted use and confirmations | + +### Cursor and shortcut persistence + +| Symbol | Address | Role | +|---|---:|---| +| `UIElementManager::SetCursor` | `0x0045A800` | Set current and optionally default cursor | +| `UIElementManager::CheckCursor` | `0x0045ABF0` | Captured/hovered/default cursor precedence | +| `UIElement::SetCursor` / `UnSetCursor` | `0x0045FF50` / `0x0045FF80` | Widget cursor changes | +| `MediaMachine::Update_Cursor` | `0x00465A80` | Apply state `MediaDescCursor` to a widget | +| `ClientUISystem::UpdateCursorState` | `0x00564630` | Global cursor enum selection | +| `ShortCutData::ShortCutData(int,uint,uint)` | `0x005D55E0` | Three-field shortcut record | +| `ShortCutManager::Pack` / `UnPack` | `0x005D5710` / `0x005D5820` | Count + packed shortcut entries | +| `ShortCutManager::AddShortCut` | `0x005D5790` | Validate index and copy all three fields | + +--- + +## 2. Exact shortcut representation and spell-bar separation + +### 2.1 Retail in-memory and packed shape + +`acclient.h` defines: + +```text +ShortCutData : PackObj { + int index_; + uint objectID_; + uint spellID_; +} + +ShortCutManager : PackObj { + ShortCutData* shortCuts_[18]; +} + +PlayerModule : PackObj { + ShortCutManager* shortcuts_; + PackableList favorite_spells_[8]; + ... +} +``` + +The C++ object occupies 16 bytes because it includes the `PackObj` vtable pointer. +The serialized entry is **12 bytes**, not 16: signed/unsigned 32-bit index, +32-bit object id, and one 32-bit spell word. `ShortCutManager::pack_size` starts +at four bytes for the count and adds each entry's 12-byte pack size. + +ACE and holtburger interpret the final 32-bit word as a `LayeredSpell` encoded as +`u16 spellId, u16 layer`. That interpretation is wire-compatible with retail's +single `uint spellID_` field: + +```text +rawSpellWord = spellId | (layer << 16) +``` + +Therefore the faithful model must retain the raw 32-bit word losslessly. It may +offer `SpellId`/`Layer` accessors, but those are a view over the word, not two +additional wire fields. + +**Current documentation correction:** `PlayerDescriptionParser.ShortcutEntry`'s +comment says "16 bytes wire size". Its actual fields and parser consume 12 bytes; +16 is only the retail C++ object size including the vptr. + +### 2.2 Manager pseudocode + +```text +ShortCutManager.AddShortCut(entry): // 0x005D5790 + if entry.index < 0 or entry.index >= 18: + return false + + if shortCuts[entry.index] already exists: + copy index, objectID, and raw spellID into it + return true + + allocate a ShortCutData + if allocation succeeds: + copy index, objectID, and raw spellID + store pointer (null on allocation failure) + return true // retail behavior + +ShortCutManager.Pack(dst, available): // 0x005D5710 + required = pack_size() + if available < required: + return required + reserve/write a u32 count + for slots 0..17: + if entry exists: + pack its three 32-bit fields + count++ + write count into reserved word + return required + +ShortCutManager.UnPack(src): // 0x005D5820 + destroy existing entries + count = read u32 + repeat count times: + read 12-byte ShortCutData + AddShortCut(entry) // rejects bad indices + return true +``` + +The pseudo-C labels the 12-byte entry unpack call as +`HookAppraisalProfile::UnPack`; this is an analysis alias collision. The field +count, constructor, manager copy, ACE, and holtburger all establish the shortcut +shape. + +### 2.3 The retail toolbar is object-only in this build + +```text +gmToolbarUI.UpdateFromPlayerDesc(): // 0x004BF810 + FlushShortcuts() + module = query CPlayerModule + if module and module.shortcuts: + for slot in 0..17: + entry = module.shortcuts.shortCuts[slot] + if entry != null and entry.objectID != 0: + AddShortcut(entry.objectID, slot, send=false) + UpdateAmmoID() +``` + +`gmToolbarUI::AddShortcut` itself constructs +`CShortCutData(slot, objectId, rawSpellWord=0)` when it sends a toolbar mutation. +No `gmToolbarUI` path restores or renders `spellID_` when `objectID_ == 0`. + +The eight `PlayerModule::favorite_spells_` lists are serialized separately under +the player-description `SpellLists8` flag. ACE also exposes separate +`AddSpellFavorite (0x01E3)` and `RemoveSpellFavorite (0x01E4)` actions. They feed +spell-bar UI, not `gmToolbarUI`'s 18 object slots. + +**Implementation constraint:** preserve the raw shortcut spell word through +parse/store/write even though this toolbar ignores it. Do not add spell icons or +spell casting to `gmToolbarUI`; implement the eight favorite-spell lists as a +separate subsystem. + +--- + +## 3. `gmToolbarUI` input, use, and selection pseudocode + +### 3.1 Global input messages + +`ListenToGlobalMessage(1, action)` ignores shortcut actions while the stack-size +entry owns focus. Otherwise: + +| Action ids | Result | +|---|---| +| `0x10000042..0x1000004D` | `UseShortcut(action - 0x10000042, use=true)` for slots 0..11 | +| `0x1000004E..0x10000059` | `UseShortcut(action - 0x1000004E, use=false)` for slots 0..11 | +| `0x10000132..0x10000137` | Use slots 12..17 | +| `0x10000138..0x1000013D` | Select slots 12..17 | +| `0x1000010D` | Create a shortcut to the selected object, auto-slot | + +When the stack entry has focus, action `0x27` restores its text from the global +split size and relinquishes focus. The input map identifies `0x27` as the commit/ +escape-style action in this context; its human key name is not recovered here. + +### 3.2 Use/select/target ordering + +```text +gmToolbarUI.UseShortcut(slot, use): // 0x004BD350 + if slot outside the visual slot array: return + cell = slot's first UIItem + if no UIItem: return + + if ClientUISystem.targetMode != NONE: + if cell.itemID != 0: + ExecuteTargetModeForItem(cell.itemID, targetMode) + SetTargetMode(NONE) // always clear here + return + + if use: + ItemHolder.UseObject(cell.itemID, 0, 0) + else: + ACCWeenieObject.SetSelectedObject(cell.itemID, 0) +``` + +Target mode is thus checked before both normal use and selection. Toolbar target +dispatch is one-shot because this method explicitly clears the mode after +dispatch. + +### 3.3 Use and Examine buttons + +On button click (`ListenToElementMessage`, message `1`): + +```text +Use button 0x1000019D: + if selectedID != 0: ItemHolder.UseObject(selectedID, 0, 0) + else: SetTargetMode(USE) + +Examine button 0x100001A5: + if selectedID != 0: ExamineObject(selectedID) + else: SetTargetMode(EXAMINE) + +Combat indicators 0x10000192..0x10000195: + ToggleCombatMode() +``` + +The seven panel buttons are discovered in `PostInit` from element attribute +`0x10000029`; `RecvNotice_SetPanelVisibility(panelId, visible)` sets button state +`6` while visible and state `1` while hidden. + +--- + +## 4. Toolbar drag/drop pseudocode + +### 4.1 Eligibility + +`IsShortcutEligible(object)` accepts only when all of the following retail tests +pass: + +1. Object exists. +2. A particular PWD bitfield condition either is absent or the object is a player. +3. Objects whose `InqType()` includes `0x10` are rejected unless `IsPlayer()`. +4. If the object has a container, that container is not the active vendor. + +The decompiler has replaced the first bit's source enum name with a raw `4`; keep +the numeric branch in the pseudocode fixture and recover the PWD property name +before introducing a public enum. + +`CreateShortcutToItem` additionally requires ownership. With its pickup flag set, +it first attempts `PlaceInBackpack`; with the flag clear it reports the appropriate +not-owned/ineligible/full/duplicate message. For a specified slot it removes any +existing occurrence of the same object and adds it at that slot. For auto-slot it +finds an empty slot; a duplicate does not get added again. + +### 4.2 Remove-on-lift + +```text +RecvNotice_ItemListBeginDrag(sourceList): // 0x004BD930 + find sourceList's index in the 18 slots + item = sourceList.first UIItem + if item.itemID != 0: + RemoveShortcut(item.itemID, send=true) + m_lastShortcutNumDragged = source index +``` + +`RemoveShortcut` flushes the visual slot, clears the object's shortcut number, +then, when `send=true`, sends `Event_RemoveShortCut(index)` and removes that slot +from `PlayerModule`. Releasing a lifted shortcut off the toolbar leaves it removed. + +### 4.3 Empty-slot search is cyclic to the right + +```text +GetFirstEmptyShortcutToTheRightOf(slot): // 0x004BD560 + search slot+1 .. 17 + if none, search 0 .. slot + return first empty, or -1 +``` + +It is not an inventory grid index, the original source index, or a global +first-empty search unless wraparound reaches slot zero. + +### 4.4 Drop release branches + +`InqDropIconInfo` supplies the dragged object id and `DropItemFlags`. The exact +source-flag enum names are not present in the named body, but the masks and branch +effects are exact: + +```text +HandleDropRelease(message): // 0x004BE7C0 + sourceElement = message.drag source + targetElement = message.drop target + if either is null: return + + if target element id == inventory button 0x100001B1: + (objectId, flags) = InqDropIconInfo(sourceElement) + if (flags & 0xE) == 0: + object = lookup objectId + if owned: PlaceInBackpack(objectId, 0) + else: AttemptToPlaceInContainer(objectId, playerId, ..., quiet=true) + if operation reports false: clear object's waiting state + return + + targetSlot = first shortcut slot that is an ancestor of targetElement + if no target slot: return + (objectId, flags) = InqDropIconInfo(sourceElement) + if objectId == 0: return + + if (flags & 0xE) == 0: // fresh/non-alias source + displaced = RemoveShortcutInSlotNum(targetSlot, send=true) + CreateShortcutToItem(objectId, targetSlot, pickup=true, quiet=false) + if displaced != 0 and displaced != objectId: + empty = GetFirstEmptyShortcutToTheRightOf(targetSlot) + if empty != -1: AddShortcut(displaced, empty, send=true) + + else if (flags & 4) != 0: // shortcut-alias source + displaced = RemoveShortcutInSlotNum(targetSlot, send=true) + AddShortcut(objectId, targetSlot, send=true) + if displaced != 0 and displaced != objectId + and IsShortcutSlotAvailable(m_lastShortcutNumDragged): + AddShortcut(displaced, m_lastShortcutNumDragged, send=true) +``` + +This distinction is behaviorally important. A fresh inventory item dropped onto +an occupied slot sends the displaced item to the first empty slot cyclically to +the right of the target. Only a dragged shortcut alias attempts to restore the +displaced item to `m_lastShortcutNumDragged`. + +### 4.5 Full-stack merge rekey + +```text +RecvNotice_FullMergingItem(oldId, newId): // 0x004BE9B0 + slot = RemoveShortcut(oldId, send=true) + if slot != -1: + CreateShortcutToItem(newId, slot, pickup=false, quiet=true) +``` + +This body updates the first matching shortcut returned by `RemoveShortcut`. +Whether duplicate object shortcuts can exist through external player-description +data is unresolved; `CreateShortcutToItem` normally prevents duplicates. + +--- + +## 5. Selected-object health, mana, and stack behavior + +### 5.1 Initial binding + +`PostInit` binds the selected field and these children: + +| Element | Retail member | Initial state | +|---:|---|---| +| `0x1000019F` | selected name | empty | +| `0x100001A1` | health meter | hidden | +| `0x100001A2` | mana meter | hidden | +| `0x100001A3` | stack-size text | hidden, `NumberInputFilter` | +| `0x100001A4` | stack slider | hidden | + +### 5.2 Selection refresh + +```text +HandleSelectionChanged(): // 0x004BF380 + if cached selected id changed: + cache selectedID + clear name + selected field state = 0 + if health meter visible: + QueryHealth(0) + hide health meter + if mana meter visible: + QueryItemMana(0) + hide mana meter + hide stack entry and slider + + if selected id == 0: + Use button state = 1 + return + object = lookup selected id + if object missing: return + + set selected name: + normal object: name, or formatted name + count when stacked + owned coinstack: formatted coin total/name path + + splitSize = 1 + maxSplitSize = 1 + hide stack controls + + if stackSize > 1: + initialSplit = stackSize + if object is in active vendor and passes vendor type mask: + initialSplit = 1 + splitSize = initialSplit + maxSplitSize = max(stackSize, 1) + entry text = splitSize + slider value attribute 0x86 = splitSize / maxSplitSize + selected field state = 0x1000000C // stacked + show entry and slider + else: + selected field state = 0x1000000B // object selected + if object is player or pet or attackable: + QueryHealth(selected id) + else if object is owned: + QueryItemMana(selected id) + + construct ItemUses(useability) + if no combat use, not relevant item types, and not useable: + Use button state = 13 // ghosted + else: + Use button state = 1 +``` + +The exact item-type mask in the use-button ghost test is visible as separate +`InqType() & 4`, `& 2`, and `& 8` checks. Preserve those numeric tests until the +retail `ITEM_TYPE` names are pinned. + +### 5.3 Meter notices + +```text +RecvNotice_UpdateObjectHealth(id, fraction): // 0x004BD110 + if id != cached selected id: return + if health meter hidden: show it + set meter float attribute 0x69 = fraction + +RecvNotice_UpdateItemMana(id, fraction, valid): // 0x004BD0C0 + if id != cached selected id: return + if not valid: + QueryItemMana(0) + return + if mana meter hidden: show it + set meter float attribute 0x69 = fraction +``` + +### 5.4 Stack editing + +- `RecvNotice_SplitStack(selectedId)` activates and selects all text only when the + selected object still has a stack size greater than one. +- On entry activation, select all text. +- On entry deactivation, parse unsigned text, clamp to `1..maxSplitSize`, rewrite + the text if clamped, update `GenItemHolder::splitSize`, update slider attribute + `0x86 = splitSize/maxSplitSize`, and broadcast the stack-slider notice. +- Slider message `0xA` maps its normalized value to an integer in + `1..maxSplitSize`, stores it, rewrites the entry, and broadcasts the notice. + +**Unresolved:** Binary Ninja lost the exact floating expression immediately before +the slider's `1 - _ftol2()` conversion at `0x004BF02D`. The clamp, endpoints, and +text update are exact, but the intermediate rounding direction must be recovered +from Ghidra/disassembly before writing conformance vectors. + +--- + +## 6. `DetermineUseResult` and `CPlayerSystem::UsingItem` + +`DetermineUseResult` returns a numeric primary-use classification. The meanings +are established by the switch in `CPlayerSystem::UsingItem @ 0x00562F70`: + +| Result | `UsingItem` behavior | +|---:|---| +| `0` | No classified primary action | +| `1` | Item has a direct/targeted `ItemUses` use path | +| `2` | `PlaceInBackpack` | +| `3` | `AutoWield(..., SLOT_SIDE_RIGHT, ...)` | +| `4` | `AutoSort` | +| `5` | Attempt to open secure trade negotiations | +| `6` | Open salvage panel | +| `7` | Begin game with object | +| `8` | `AutoWield(..., SLOT_SIDE_LEFT, ...)` | + +### 6.1 Classification pseudocode + +```text +DetermineUseResult(object): // 0x00588460 + if object is in a container or has the relevant PWD flag, + or object is in the currently viewed ground container: + if not wielded by somebody else: + if object has item/container capacity and is not component pack: + return 2 // pick up/open carrier path + + type = object.InqType() + + if owned by player: + if combat-use or type bit 0x8000 or PWD bit 0x20000000: + if not currently wielded by player: + return 8 when PWD bit 0x40000000 is set, else 3 + + if valid-location masks contain an unworn usable location: + return 4 + + if type has 0x20000000: + return 6 + + else if type has its high bit set: + return 7 + + if ItemUses(useability).IsUseable(): + return 1 + + if object is another player: + return 5 + + return 0 +``` + +The first capacity bit expression is corrupted into a string address by Binary +Ninja. Capacity fields and `IsComponentPack` make the branch intent clear, but the +missing bit name/value is **unresolved** and must be recovered before porting. + +`CPlayerSystem::UsingItem` first executes result `2..8`, then independently: + +- sends `OpenContainedContainer` for a player-owned container; and +- for a non-owned, directly usable, non-targeted container, calls + `AttemptSetGroundObject`. + +These are not mutually exclusive with the classification switch. A pure policy +port must model the complete ordered action list, not reduce the result to a +single modern enum and accidentally omit the post-switch container steps. + +--- + +## 7. `ItemHolder::UseObject` pseudocode + +```text +UseObject(objectId, useCurrentSelection, bypassClassification): // 0x00588A80 + ui = GetUISystem() + if ui is null: return + object = lookup objectId + if object is null: return + + if now is not later than m_timeLastUsed + 0.2 seconds: return + m_timeLastUsed = now + + if player is not ready for an inventory request: return + if active vendor exists and object.containerID == vendorID: return + + classified = false + if bypassClassification == 0: + result = DetermineUseResult(object) + if result in 2..7: // exact bound; excludes 8 + classified = true + + uses = ItemUses(object.useability) + + if classified: + PlayerSystem.UsingItem(objectId, true, bypassClassification) + return + + if object.tradeState == 1: + display "cannot use while trading" message + return + + if object.location == 0 and least-limited source use requires wielded: + display "must wield" message + return + + if uses.IsUseable_Targeted(): + if useCurrentSelection == 0: + ItemHolder.targetingObject = objectId + SetTargetMode(USE_TARGET) + display "Choose a target..." + return + + target = ACCWeenieObject.selectedID + if target == 0: + display "Select your target..." + return + + if TargetCompatibleWithObject(target, objectId, showErrors=false, + showUsing=true): + Event_UseWithTargetEvent(objectId, target) + IncrementBusyCount() + PlayerSystem.UsingItem(objectId, true, bypassClassification) + return + + if uses.IsUseable(): + if PK altar flag: show PK altar confirmation; return + if NPK altar flag: show NPK altar confirmation; return + if volatile-rare flag and preference requires confirmation: + show volatile-rare confirmation; return + + Event_UseEvent(objectId) + IncrementBusyCount() + PlayerSystem.UsingItem(objectId, true, bypassClassification) + display "Approaching" for creature/player-like type, else "Using" + return + + PlayerSystem.UsingItem(objectId, false, bypassClassification) + if object is not self: + if object has the no-open/close restriction bit: + display restricted message; return + if attackable and combat mode is noncombat: + display switch-to-combat message; return + if not attackable or combat mode is noncombat: + display "cannot be used" message; return +``` + +Important boundaries: + +- The 200 ms throttle timestamp advances before the ready/vendor/legality gates. +- `UseObject` owns confirmation routing and busy-count increments. +- `arg2` is established by call sites as "use the current selected target now" + versus "enter target mode". `arg3` bypasses classification in several internal + paths; its original source-level name remains unresolved. +- The `2..7` bound excludes `DetermineUseResult == 8` even though + `CPlayerSystem::UsingItem` has a case for 8. Preserve this asymmetry; do not + normalize it without tracing the left-side-wield call path. + +### 7.1 Target acquisition completion + +```text +TargetAcquired(targetId): // 0x00588EF0 + sourceId = ItemHolder.targetingObject + ItemHolder.targetingObject = 0 + if not TargetCompatibleWithObject(targetId, sourceId, + showErrors=false, showUsing=true): + return + source = lookup sourceId + target = lookup targetId + if source and target: + if source is mana stone requiring confirmation: + reject no-mana target or show mana-stone confirmation + return + if source is salvage-like and craft-success-dialog preference requires it: + show salvage confirmation + return + Event_UseWithTargetEvent(sourceId, targetId) + IncrementBusyCount() + PlayerSystem.UsingItem(sourceId, true, 0) +``` + +ACE and holtburger confirm the wire actions after this policy: + +```text +Use (0x0036): u32 objectId +UseWithTarget (0x0035): u32 sourceObjectId, u32 targetObjectId +``` + +--- + +## 8. `AttemptPlaceIn3D` pseudocode + +```text +AttemptPlaceIn3D(itemId, targetId, allowGroundFallback): // 0x00588600 + if player not ready for inventory request: return false + item = lookup itemId + if item missing: return false + + if targetId == playerId: + return PlaceInBackpack(itemId, 0) + + if item is not owned by player: + display "You must first pick up..." + return false + + if item.tradeState != 0: + display "You are trading..." + return false + + if targetId == 0: + if not allowGroundFallback: return false + goto PLACE_ON_GROUND + + if AttemptMerge(itemId, targetId, quiet=false): + return true + + target = lookup targetId + if target missing: + if allowGroundFallback: goto PLACE_ON_GROUND + return false + + if target passes the retail vendor-drop PWD bit test: + if splitSize == maxSplitSize: + AttemptSellToVendor(targetId, itemId) + return false // exact retail return + display "must split the stack before..." + return false + + if preference DragItemOnPlayerOpensSecureTrade and target.IsPlayer(): + AttemptToTradeItem(targetId, itemId) + return false // exact retail return + + if target.InqType() == 0x10: + item.UIAttemptGive(targetId, GetObjectSplitSize(item)) + return true + + if target.IsContainer(): + if target is locked: display locked message; return false + if targetId != ui.groundObject: + display "must open first"; return false + placed = AttemptToPlaceInContainer(itemId, targetId, ..., quiet=true) + if placed: return true + + if allowGroundFallback: goto PLACE_ON_GROUND + display formatted "Cannot give item to target" + return false + +PLACE_ON_GROUND: + playerPhysics = lookup player physics + if playerPhysics missing or not on_ground: + display "You cannot do that in mid air" + return false + if splitSize < maxSplitSize: + item.UIAttemptSplitTo3D(splitSize) + return true + if item.current_state != IN_3D_VIEW: + item.UIAttemptPutIn3D() + return true + display "Move cancelled" + return false +``` + +The return value means neither strictly "a server message was sent" nor strictly +"the item moved": vendor and secure-trade attempts deliberately return false. +Callers that use it only as an action-success boolean will diverge. + +**Semantic name uncertain:** the vendor-drop test appears in pseudo-C as a raw +bitfield byte test. The branch behavior is exact, but its PWD flag name must be +recovered before making a reusable Core policy field; do not substitute a check +against `ClientUISystem.vendorID` without that evidence. + +--- + +## 9. Target-mode click ordering + +### 9.1 Item lists + +For left-click message `0x1C`, parameter `7`: + +```text +UIElement_ItemList.ListenToElementMessage: // 0x004E4D50 + item = item under mouse + if targetMode != NONE: + HandleTargetedUseLeftClick(item) + return base-list handler result // no selection/open fallback + + if item.itemID != 0: + apply single-selection visual if configured + SetSelectedObject(item.itemID) + if this is a container list and child list exists: + bind/open child container and scroll it home +``` + +`HandleTargetedUseLeftClick` dispatches `spellID` first, otherwise `itemID`, using +the current target mode. Thus a valid or invalid target-mode click is consumed +before selection/container opening. The body itself does not clear target mode. + +Other item-list mouse actions: + +- parameter `8`: select the item, then Examine; a spell-only cell examines spell. +- parameter `0xA`: double-click UseObject only when not vendor/salvage and either + not a container list or the item is the current ground object. + +### 9.2 Toolbar slots + +Toolbar `UseShortcut` follows the same target-first rule, but then explicitly +calls `SetTargetMode(NONE)`. This is a real caller difference and should remain in +the orchestration policy unless a later input-map trace proves a shared deferred +clear. + +### 9.3 World clicks + +`CPlayerSystem::OnAction @ 0x00561890`, on the world-selection action: + +```text +if targetMode == USE_TARGET: + SetTargetMode(NONE) // clear before completion + ItemHolder.TargetAcquired(foundObjectId) + consume + +if targetMode == EXAMINE: + SetSelectedObject(foundObjectId) + SetTargetMode(NONE) + ExamineObject(foundObjectId) + consume + +SetSelectedObject(foundObjectId) +consume +``` + +**Unresolved:** the visible `CPlayerSystem::OnAction` branch does not separately +execute `TARGET_MODE_USE`; it falls through to selection, while toolbar/item-list +paths route mode `USE` through `ExecuteTargetModeForItem`. SmartBox/input-map +coordination likely supplies the remaining world-use path. Pin that call chain in +Ghidra/cdb before centralizing all world clicks behind one policy. + +### 9.4 Target-mode lifecycle + +`SetTargetMode` registers input map `0x1000000B` at priority `0x7D0` for modes +1..3 and unregisters it for `NONE`, then refreshes the cursor. `ClientUISystem` +also owns `m_bLeaveTargetMode`, processed from `UseTime @ 0x005651F0`; the named +pseudo-C does not make the originating input transition sufficiently clear. +Do not replace this with an arbitrary timeout or focus-loss rule. + +--- + +## 10. Full `ClientUISystem` global cursor catalog + +`ClientUISystem::UpdateCursorState @ 0x00564630` resolves an enum through master +`EnumIDMap` entry `6`, then calls `UIElementManager::SetCursor`. "Found" below +means `SmartBox::get_found_object_id() != 0`. + +| Precedence branch | Not found enum | Found enum | Hotspot | +|---|---:|---:|---:| +| Busy count `> 0` | `0x0E` | `0x0F` | `(0,0)` | +| Target `USE_TARGET` | `0x27` pending | `0x28` compatible / `0x29` incompatible | `(14,14)` | +| Target `USE` | `0x0C` | `0x0D` | `(14,14)` | +| Target `EXAMINE` | `0x0A` | `0x0B` | `(0,0)` | +| No target mode, Magic combat | `0x05` | `0x06` | `(0,0)` | +| No target mode, Melee or Missile combat | `0x03` | `0x04` | `(0,0)` | +| No target mode, other/NonCombat | `0x01` | `0x02` | `(0,0)` | + +Enum values `0x07..0x09` have no reachable branch in this method. They may be +used by another system or retained in the DAT map; do not assign semantics merely +to make the range contiguous. + +Exact decision order: + +```text +UpdateCursorState(): // 0x00564630 + found = SmartBox.foundObjectId != 0 + enum = found ? 2 : 1 + hotspot = (0,0) + + if busyCount > 0: + enum = found ? 0x0F : 0x0E + else: + switch targetMode: + NONE: + if combat is MELEE or MISSILE: enum = found ? 4 : 3 + else if combat is MAGIC: enum = found ? 6 : 5 + USE: + enum = found ? 0x0D : 0x0C + hotspot = (14,14) + EXAMINE: + enum = found ? 0x0B : 0x0A + USE_TARGET: + hotspot = (14,14) + if not found: enum = 0x27 + else if IsTargetCompatibleWithTargetingObject(foundId): enum = 0x28 + else: enum = 0x29 + + did = DBObj.GetDIDByEnum(enum, table=6) + if did changed: + currentCursorDid = did + UIElementManager.SetCursor(did, hotspot, setAsDefault=true) +``` + +Production DAT golden values already pinned in tests: + +| Enum | RenderSurface DID | +|---:|---:| +| `0x27` | `0x06004D73` | +| `0x28` | `0x06005E6B` | +| `0x29` | `0x06005E6A` | + +The DIDs for `0x01..0x0F` should be committed as DAT conformance fixtures before +implementation. The enum behavior above is exact even without those media IDs. + +### 10.1 Widget cursor precedence is separate and temporal + +The global cursor selected above is stored as `UIElementManager`'s **default**. +Retail widget states can supply their own `MediaDescCursor`: + +```text +MediaMachine.Update_Cursor(cursorMedia): // 0x00465A80 + if media.file is valid: + owner.SetCursor(file, hotspot) + else: + owner.UnSetCursor() + +UIElement.SetCursor(...): // 0x0045FF50 + store widget cursor + UIElementManager.CheckCursor() + +UIElementManager.CheckCursor(): // 0x0045ABF0 + if captured element exists and HasCursor(): + apply captured element cursor, setAsDefault=false + else if last-entered element exists and HasCursor(): + apply hovered element cursor, setAsDefault=false + else: + apply global default cursor, setAsDefault=true +``` + +Therefore retail precedence is: + +1. captured widget cursor; +2. last-entered/hovered widget cursor; +3. `ClientUISystem` global default cursor. + +However, `ClientUISystem::UpdateCursorState` immediately applies its newly chosen +cursor as well as storing it as default. A later widget cursor transition invokes +`CheckCursor` and may override it; a later busy/target/combat/found-object change +may reassert the global cursor. The exact behavior is event-ordered, not a static +rule that target cursors always beat slot cursors or vice versa. + +**Plan correction:** do not hard-code "global target bullseye always wins widget +cursor" as retail behavior. Port the default-versus-local ownership and the +`CheckCursor` order, then pin target-mode-over-slot scenarios with event-sequence +tests. + +--- + +## 11. Current acdream gaps this oracle retires or sharpens + +This section describes the inspected code as of 2026-07-10; it is not an +implementation patch. + +1. `PlayerDescriptionParser.ShortcutEntry` consumes the correct 12 bytes but calls + it 16 bytes and immediately separates the raw word into two fields. Preserve a + raw-word round trip and expose layered accessors. +2. `ShortcutStore` is GUID-only, so any mutation discards the raw spell word and + signed index representation. The toolbar may project object IDs, but the session + store cannot if it is also the persistence/wire owner. +3. `ToolbarController.HandleDropRelease` uses the shortcut-source restoration + branch for every source. Fresh inventory drops need the cyclic-right empty-slot + branch. +4. Toolbar input actions, seven panel buttons, Use, Examine, mana, and stack + controls are not fully wired. +5. `ItemInteractionController.ActivateItem` is a useful orchestration seam, but it + is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations, + the complete `DetermineUseResult` classification, container post-actions, + `AttemptPlaceIn3D`, and retail busy semantics. +6. Selection remains owned by `GameWindow._selectedGuid`; the previously documented + `Core.SelectionState` file is absent in this worktree. +7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog + also includes `0x01..0x06` and `0x0A..0x0F`. +8. Current cursor resolution uses a modern static priority chain. Retail instead + has a global-default selector plus captured/hovered widget cursor arbitration. +9. Current target controller clears its pending source before compatibility and + sends. That matches the one-shot `TargetAcquired` source clear, but click + consumption and target-mode clearing differ by retail caller and must be tested + at the router boundary. + +--- + +## 12. Required conformance fixtures before production port + +1. **Shortcut bytes:** count plus entries containing negative index, object-only, + nonzero layered spell word, duplicate index, and out-of-range index. +2. **Toolbar input:** every action range above, stack-entry focus suppression, and + CreateShortcut on selected/no-selected object. +3. **Drop planner:** fresh inventory to empty/occupied; shortcut alias to + empty/occupied; cyclic-right wrap; no empty slot; duplicate object; release off + bar; inventory-button drop. +4. **Selected strip:** unselected, health target, owned mana item, ordinary stack, + vendor stack, coinstack, and unusable item. +5. **Use policy:** all `DetermineUseResult` values; direct, targeted, confirmation, + vendor, trade, busy/not-ready, source-location, and cannot-use paths. +6. **Placement:** merge, vendor full/partial stack, secure trade, give, locked/ + unopened/open container, grounded drop, mid-air rejection, split-to-ground, and + already-in-3D cancellation. +7. **Target click sequences:** toolbar, inventory cell, paperdoll cell, world, + invalid target, spell-only cell, and target-mode cancel/leave. +8. **Cursor enums:** DAT-resolved DIDs for every reachable enum and event-order + sequences covering captured widget, hovered widget, global mode transition, + busy transition, and found-object transition. + +Open retail-research questions are deliberately limited to the corrupted PWD bit +names, stack-slider rounding expression, `TARGET_MODE_USE` world-click handoff, +and deferred target-mode leave event. None justifies an implementation guess or a +workaround. diff --git a/docs/research/2026-07-10-retained-widget-foundations-pseudocode.md b/docs/research/2026-07-10-retained-widget-foundations-pseudocode.md new file mode 100644 index 00000000..12cd2a79 --- /dev/null +++ b/docs/research/2026-07-10-retained-widget-foundations-pseudocode.md @@ -0,0 +1,769 @@ +# Retained widget foundations -- named-retail pseudocode + +**Date:** 2026-07-10 +**Scope:** Wave 0 oracle for `UIElement_Text`, `UIElement_Button`, raw edge +layout, UI time delivery, tooltip cancellation, and focus ordering. This note +changes no runtime behavior. + +## Sources and confidence + +Primary evidence: + +- `docs/research/named-retail/acclient_2013_pseudo_c.txt`, the Sept 2013 EoR + PDB-named client decompilation. +- `docs/research/named-retail/acclient.h`, especially + `UIElement_Text::UIText_Flag`, `UIElement_Text`, `UIElement_Button`, + `UIElementManager`, `ElementDesc`, and `Box2D`. +- Static x86 disassembly of the matching `C:\Turbine\Asheron's Call\acclient.exe` + for the expressions that both Binary Ninja and Ghidra lost around x87 + `_ftol2` calls in `UIElement::UpdateForParentSizeChange`. + +Cross-checks: + +- `docs/research/2026-06-15-layoutdesc-format.md` and the production LayoutDesc + data it records. Its property observations agree with the named code, but its + `AnchorEdges` reduction is intentionally lossy and its text-property table has + two margin fields transposed; the corrections are listed below. +- `docs/research/retail-ui/02-class-hierarchy.md` and + `docs/research/retail-ui/04-input-events.md`, the older `FUN_xxx` research. + These are useful as provenance but are superseded where they conflict with + the PDB-named client. In particular, the named client contains the full + `UIElement*` hierarchy and does **not** implement the inferred generic + `Device::RegisterTimerEvent` queue described there. +- The only vendored reference tree present in this worktree is WorldBuilder; + it does not implement the retail retained-widget system. No independent ACE, + AC2D, ACViewer, or holtburger UI implementation was available here to + corroborate these client-internal mechanics. + +Confidence is **high** for every branch below unless it is explicitly marked +uncertain. Addresses were checked against `symbols.json` or the named function +header in the pseudo-C. + +## Corrections that affect the implementation plan + +1. Retail text interactivity is **two orthogonal capabilities**, not a + three-value exclusive mode. `Editable` (`0x16`) and `Selectable` (`0x27`) + can be independently false/true. An editable, non-selectable field accepts + edits but does not begin drag selection. The modern representation should + therefore be flags or two booleans; `Display` means both are false. +2. A newly constructed Type-12 text starts with `m_bitField = 0x300` + (`DIRTY | CURSOR_VISIBLE`), not editable and not selectable. Imported labels + must not be made interactive merely because their resolved type is 12. +3. There is no evidence for the old generic Device timer queue in this build. + Retail time-driven UI is a per-frame global message (`3`) plus explicit + deadline polling by `UIElementManager::CheckTooltip`. The unpumped + `UiRoot.RegisterTimerEvent` list is not a faithful retail mechanism. +4. Raw edge modes `3` and `4` cannot be reduced to `AnchorEdges`: `3` performs + exact inclusive-pixel centering and `4` proportionally scales the individual + edge coordinate with truncation toward zero. +5. In the older LayoutDesc note, the exact text margins should be + `0x23=left`, `0x24=right`, `0x25=upper`, `0x26=lower`, as shown by + `UIElement_Text::OnSetAttribute @ 0x0046A640`. + +--- + +## 1. `UIElement_Text` + +### 1.1 Flag model and defaults + +`acclient.h` defines the exact flags: + +| Flag | Value | Meaning | +|---|---:|---| +| `UITF_EDITABLE` | `0x0001` | Editing/input capability | +| `UITF_ONE_LINE` | `0x0002` | One-line layout | +| `UITF_SELECTABLE` | `0x0004` | Selection/copy capability | +| `UITF_NO_IME` | `0x0008` | Do not enable IME | +| `UITF_OUTLINE` | `0x0010` | Outline glyphs | +| `UITF_DROPSHADOW` | `0x0020` | Drop shadow glyphs | +| `UITF_MOUSE_SELECTING` | `0x0040` | Pointer drag is active | +| `UITF_SELECTING` | `0x0080` | Selection anchor/end are active | +| `UITF_DIRTY` | `0x0100` | Glyph/layout cache dirty | +| `UITF_CURSOR_VISIBLE` | `0x0200` | Caret blink phase visible | +| `UITF_FIT_TO_TEXT` | `0x0400` | Resize element to paper | +| `UITF_TRUNCATE_TEXT_TO_FIT` | `0x0800` | Trailer/truncation path | +| `UITF_LOSE_FOCUS_ON_ESCAPE` | `0x1000` | Escape relinquishes focus | +| `UITF_LOSE_FOCUS_ON_ACCEPT_INPUT` | `0x2000` | Accept-input action relinquishes focus | + +`UIElement_Text::UIElement_Text @ 0x00468570` initializes the flag word to +`0x300`, selection/cursor positions to zero, horizontal justification to `2`, +vertical justification to `4`, margins to zero, and cursor/flash timestamps to +`-1.0`. Thus the retail default is a display-only label. + +### 1.2 Interaction-relevant property IDs + +`UIElement_Text::OnSetAttribute @ 0x0046A640` first delegates to +`UIElement_Scrollable::OnSetAttribute`, then handles these properties: + +| Property | Value kind | Exact effect | +|---:|---|---| +| `0x14` | enum | Horizontal justification | +| `0x15` | enum | Vertical justification | +| `0x16` | bool | `SetEditable`; then recompute mouse visibility | +| `0x17` | `StringInfo` | Set contents, or clear when invalid | +| `0x1A` | array/data-id | Re-resolve current font | +| `0x1B` | array/color | Re-resolve current font color | +| `0x1D` | array/color | Re-resolve tag font color | +| `0x1E` | integer | Maximum characters | +| `0x1F` | bool | `SetNoIme` | +| `0x20` | bool | `SetOneLine` | +| `0x21` | bool | `SetOutline` | +| `0x22` | color | Outline color; then font reset | +| `0x23` | integer | Left margin | +| `0x24` | integer | Right margin | +| `0x25` | integer | Upper margin | +| `0x26` | integer | Lower margin | +| `0x27` | bool | `SetSelectable`; then recompute mouse visibility | +| `0x28` | bool | Glyph list `TrimFromTop` | +| `0x29` | bool | `SetFitToText` | +| `0xC7` | `StringInfo` | Enable/define truncation trailer; invalid disables it | +| `0xCB` | bool | Lose focus on Escape | +| `0xCC` | bool | Lose focus on accept-input | + +### 1.3 Capability setters + +Sources: `SetEditable @ 0x004680D0`, `SetOneLine @ 0x00468170`, and +`SetSelectable @ 0x004681C0`. + +```text +SetEditable(value): + if value == current Editable: return + + if value is false: + if this has global focus AND Selectable is false: + RelinquishFocus() + else if TruncateTextToFit is true: + clear TruncateTextToFit + mark dirty and dirty the root + + set Editable = value + +SetSelectable(value): + if value == current Selectable: return + + if value is false: + if this has global focus AND Editable is false: + RelinquishFocus() + else if TruncateTextToFit is true: + clear TruncateTextToFit + mark dirty and dirty the root + + set Selectable = value + if the internal selection-direction marker is active: + clear selecting/mouse-selecting and zero both selection endpoints + +SetOneLine(value): + if value differs: + set OneLine = value + mark dirty and dirty the root +``` + +The high internal selection-direction bit checked by `SetSelectable` and +`SetCursorPosition` is not named in `UIText_Flag`; it is deliberately not given +a speculative public name here. + +`SetTruncateTextToFit @ 0x00467480` refuses to enable truncation whenever +either `Editable` or `Selectable` is set. + +### 1.4 Mouse visibility + +Sources: `UIElement_Text::GetShouldBeMouseVisible @ 0x00467460` and +`UIElement::GetShouldBeMouseVisible @ 0x004601B0`. + +```text +Text.GetShouldBeMouseVisible(): + if Editable OR Selectable: + return true + return base.GetShouldBeMouseVisible() + +Base.GetShouldBeMouseVisible(): + return explicit mouse-visible/interaction flag OR valid tooltip text +``` + +Consequently, a plain imported label does not claim the mouse unless another +base property (for example a tooltip/explicit mouse role) says it should. + +### 1.5 Mouse down, selection, and activation + +Sources: `MouseDown @ 0x00469370`, `MouseUp @ 0x004694F0`, and +`MouseMove @ 0x004695F0`. + +```text +MouseDown(screenX, screenY, mouseAction): + remember whether this already had focus + call Scrollable.MouseDown(...) + + if hidden/blocked base flags reject input: return + if mouseAction is not one of the accepted text actions (5, 6, 7): return + if the base action-enable bit is false: return + + local = screen point - screen origin - margins + + if mouseAction == 7 AND (Editable OR Selectable): + read bool property 0xD1 + if this did not previously have focus AND property 0xD1 is true: + SelectAll() + return + + hitPos = DeterminePositionFromXY(local) + previousAnchor = selectionStart when the internal selection direction + is active, otherwise current cursor + SetCursorPosition(hitPos, DontSelectText) + Activate() // activate the owning root + + if Selectable: + set Selecting and MouseSelecting + if Shift is down: + selectionStart = previousAnchor + selectionEnd = cursor + else: + selectionStart = cursor + +MouseMove(screenX, screenY): + if not MouseSelecting: + call base MouseMove and return + local = screen point - screen origin - margins + SetCursorPosition(DeterminePositionFromXY(local), Default) + +MouseUp(screenX, screenY, mouseAction): + remember whether a glyph callback is registered for mouseAction + call base MouseUp(...) + + if MouseSelecting: + set cursor from release point using Default + clear MouseSelecting + + if a glyph callback was registered: + glyph = glyph at release point + if glyph has a callback: invoke it with mouseAction +``` + +Important boundary: `Editable` alone allows caret placement and root +activation, but only `Selectable` starts pointer selection. The method calls +`Activate`, not `TakeFocus`, so the focus transition remains governed by the +root's remembered focus element and explicit `TakeFocus` requests. + +### 1.6 Focus registration and key capabilities + +Sources: `RegisterInputMaps @ 0x00467350`, `UnregisterInputMaps @ +0x004673D0`, `ListenToElementMessage @ 0x004687C0`, and `OnAction @ +0x0046A260`. + +```text +RegisterInputMaps(priority): + unregister this callback first + result = Scrollable.RegisterInputMaps(priority) + if Editable: + register input map 1 at priority - 10 + register input map 7 at priority + if Editable OR Selectable: + register input map 8 at priority + return OR of registration results + +On focus message 0x2F for this element: + if neither Editable nor Selectable: delegate to base + + if Editable: + on gain: + register as ICIDM input handler + turn IME on unless disabled + ask Keystone to lose its external focus + on loss: + unregister ICIDM input handler + + on gain: + ICIDM.SetTextMode(true) + subscribe to global message 3 + on loss: + Deselect() + ICIDM.SetTextMode(false) + unsubscribe from global message 3 + + delegate to Scrollable.ListenToElementMessage +``` + +Map 8 exposes cursor movement, line/document home/end, up/down/page movement, +and Copy for both editable and selectable text. Editable-only maps add character +editing. `Cut`, `Paste`, and deletion helpers independently guard on +`UITF_EDITABLE`, so merely selectable text cannot mutate its contents. + +The action at `0x25` relinquishes focus when `0xCC` is set; the action at +`0x27` relinquishes focus when `0xCB` is set. Actions `0x26` and `0x28` are +forward-delete and backspace behavior respectively, deleting the active +selection first. + +### 1.7 Per-frame caret work + +`UIElement_Text::Global_Loop @ 0x00469FC0` runs on global message `3`. +It checks asynchronous string downloads, then, only while +`(Editable || Selectable) && HasFocus`, uses `GetCaretBlinkTime()` converted +from milliseconds to seconds to flip `UITF_CURSOR_VISIBLE` and dirty the last +caret rectangle. This is subscription-driven; it is not a registered timer +object. + +--- + +## 2. `UIElement_Button` + +### 2.1 State inputs and numeric states + +Source: `UIElement_Button::UpdateState_ @ 0x00471CF0`, verified against +the matching x86 instructions. + +The method reads these bool properties: + +- `0x0D`: ghosted/disabled role; +- `0x0E`: selected/toggled role; +- `0x13`: allow rollover-state change role. + +It combines them with `mousePressedOnButton` and the inherited +`mouseOverTop` bit. The standard state IDs are: + +| Visual phase | Unselected | Selected | +|---|---:|---:| +| Normal | `1` | `6` | +| Rollover / pressed outside | `2` | `7` | +| Pressed while pointer is over | `3` | `8` | +| Ghosted | `13` | `13` | + +```text +UpdateState_(): + disabled = bool property 0x0D + if disabled: + requested = 13 + else: + selected = bool property 0x0E + rolloverEnabled = bool property 0x13 + + requested = selected ? 6 : 1 + + if mousePressedOnButton OR (rolloverEnabled AND mouseOverTop): + requested = selected ? 7 : 2 + + if mousePressedOnButton AND mouseOverTop: + requested = selected ? 8 : 3 + + if ElementDesc contains requested state: + virtual SetState(requested) + else: + leave the current state unchanged +``` + +That last test is the exact fallback rule. There is no search for a nearest +standard state. A custom semantic state survives only when the newly requested +standard state is absent from the element's DAT state table. + +### 2.2 `SetState` synchronization + +Source: `UIElement_Button::SetState @ 0x00471DA0`. + +```text +SetState(requested): + if bool property 0x0B is true: // toggle behavior + selected = bool property 0x0E + if requested == 6 AND not selected: + set property 0x0E = true + return true + if requested == 1 AND selected: + set property 0x0E = false + return true + + wantsDisabled = requested == 13 + disabled = bool property 0x0D + if wantsDisabled != disabled: + set property 0x0D = wantsDisabled + return true + + return UIElement.SetState(requested) +``` + +Thus programmatic `SetState(1/6)` is also the selected-property API for a +toggle button, and `SetState(13)` is the disabled-property API. Property +changes call `UpdateState_` through `OnSetAttribute`. + +### 2.3 Mouse state machine + +Sources: `MouseDown @ 0x00471FF0`, `MouseUp @ 0x004720E0`, and +`MouseOverTop @ 0x004721F0`. + +```text +MouseOverTop(isOver): + base.MouseOverTop(isOver) + UpdateState_() + +MouseDown(x, y, mouseAction): + Text.MouseDown(...) + if inherited visibility/input gates reject: return + if mouseAction is neither 7 nor accepted alternate action 0x0A: return + + mousePressedOnButton = true + UpdateState_() + + disabled = bool property 0x0D + allowHotClickWhileDisabled = bool property 0x0C + if disabled AND not allowHotClickWhileDisabled: return + + if bool property 0x0F: // hot-click/auto-repeat + BroadcastElementMessage(2, mouseAction, 0) // immediate activation + hotClickingInProgress = true + subscribe to global message 3 + nextHotClickTime = Timer.cur_time + float property 0x10 + +MouseUp(x, y, mouseAction): + Text.MouseUp(...) + disabled = bool property 0x0D + + if mousePressedOnButton AND mouseAction is 7 or 0x0A: + emitClick = false + + if mouseOverTop AND not disabled: + if bool property 0x0B: // toggle + property 0x0E = !property 0x0E + if not hotClickingInProgress: + emitClick = true + + mousePressedOnButton = false + if hotClickingInProgress: + hotClickingInProgress = false + unsubscribe from global message 3 + + UpdateState_() + if emitClick: + BroadcastElementMessage(1, 7, 0) +``` + +Release outside never emits message `1`. During a captured press, moving outside +changes state `3/8 -> 2/7`; moving back inside restores `3/8`. A hot-click press +emits message `2` immediately and suppresses the ordinary release click. + +### 2.4 Hot-click deadline delivery and cancellation + +Sources: `ListenToGlobalMessage @ 0x00471C60`, `OnSetAttribute @ +0x00471F40`, and the button destructor at `0x00471BC0`. + +```text +On global message 3 while hotClickingInProgress: + now = Timer.cur_time + + if not mouseOverTop AND now >= nextHotClickTime: + nextHotClickTime = now + + if mouseOverTop AND now >= nextHotClickTime: + BroadcastElementMessage(2, 0, 0) + nextHotClickTime += float property 0x11 +``` + +Cancellation is explicit and idempotent at all three owner transitions: + +- mouse up clears `hotClickingInProgress` and unsubscribes from global `3`; +- changing bool property `0x0F` to false does the same; +- destruction does the same if a hot click is active. + +`OnSetAttribute` recalculates visual state for `0x0D`, `0x0E`, and `0x13`; +`0x0D` also recomputes mouse visibility. + +The descriptive names for button properties `0x0B..0x13` are not present in +the recovered headers. Their behavioral roles above are exact; do not bake the +descriptive labels into a wire/DAT enum until the master property-name table is +recovered. + +--- + +## 3. `UIElement::UpdateForParentSizeChange` + +**Oracle:** `UIElement::UpdateForParentSizeChange @ 0x00462640`. + +`Box2D` is inclusive: width is `x1 - x0 + 1`, height is +`y1 - y0 + 1`. For a root/no-parent element (or the relevant root-sizing flag), +the original parent box is `[0,0,layout.displayWidth-1,layout.displayHeight-1]` +and the current parent box is `[0,0,displayWidth-1,displayHeight-1]`. +Otherwise, both boxes come from the parent. + +```text +orig = this.GetOriginalPosition() +origParent = parent.GetOriginalPosition() or layout display box +curParent = parent.GetCurrentPosition() or current display box + +origParentWidth = origParent.x1 - origParent.x0 + 1 +origParentHeight = origParent.y1 - origParent.y0 + 1 +curParentWidth = curParent.x1 - curParent.x0 + 1 +curParentHeight = curParent.y1 - curParent.y0 + 1 + +deltaX = curParentWidth - origParentWidth +deltaY = curParentHeight - origParentHeight + +scaleX = origParentWidth != 0 ? curParentWidth / origParentWidth : 0.0 +scaleY = origParentHeight != 0 ? curParentHeight / origParentHeight : 0.0 +``` + +All ratios are floating point. Mode-4 conversion uses MSVC `_ftol2`, i.e. +truncate toward zero, not floor and not round-to-nearest. + +Define `halfTowardZero(n) = truncate(n / 2)` for signed integers. + +```text +newX0 = orig.x0 +switch LeftEdge: + 2: newX0 = orig.x0 + deltaX + 3: newX0 = halfTowardZero(curParentWidth) + - halfTowardZero(orig.width) + 4: newX0 = truncateTowardZero(orig.x0 * scaleX) + 0,1: leave orig.x0 for now + +newX1 = orig.x1 +switch RightEdge: + 1: newX1 = orig.x1 + deltaX + 3: newX1 = halfTowardZero(curParentWidth) + + halfTowardZero(orig.width) - 1 + 4: newX1 = truncateTowardZero(orig.x1 * scaleX) + 0,2: leave orig.x1 for now + +newY0 = orig.y0 +switch TopEdge: + 2: newY0 = orig.y0 + deltaY + 3: newY0 = halfTowardZero(curParentHeight) + - halfTowardZero(orig.height) + 4: newY0 = truncateTowardZero(orig.y0 * scaleY) + 0,1: leave orig.y0 for now + +newY1 = orig.y1 +switch BottomEdge: + 1: newY1 = orig.y1 + deltaY + 3: newY1 = halfTowardZero(curParentHeight) + + halfTowardZero(orig.height) - 1 + 4: newY1 = truncateTowardZero(orig.y1 * scaleY) + 0,2: leave orig.y1 for now +``` + +Then retail applies mode `0` as a preservation policy: + +```text +if current width != 0 OR current height != 0 OR preserve-current flag is set: + if LeftEdge == 0: newX0 = current.x0 + if TopEdge == 0: newY0 = current.y0 + if RightEdge == 0: newX1 = current.x1 + if BottomEdge == 0: newY1 = current.y1 +``` + +Finally: + +```text +MoveTo(newX0, newY0) +ResizeTo(newX1 - newX0 + 1, newY1 - newY0 + 1) + +if original z-level differs from current z-level: + restore original z-level + ask parent to remove/reinsert this child in z-order +return true +``` + +### Raw-mode summary + +| Raw mode | Near edge (`Left`/`Top`) | Far edge (`Right`/`Bottom`) | +|---:|---|---| +| `0` | Preserve current edge after initialization | Preserve current edge after initialization | +| `1` | Keep original near edge | Add parent size delta (stretch) | +| `2` | Add parent size delta (move with far side) | Keep original far edge | +| `3` | Recompute centered near edge | Recompute centered far edge | +| `4` | Proportionally scale original coordinate | Proportionally scale original coordinate | + +Mode `4` does **not** mean “both anchors.” That description in the earlier +LayoutDesc note was only an approximation of the resulting resize for common +assets and must not survive in the imported layout policy. + +--- + +## 4. Time delivery, tooltip cancellation, and focus ordering + +### 4.1 No generic Device timer queue in the named client + +The old `04-input-events.md` inferred a Device vtable timer API from unnamed +calls. The recovered `UIElementManager` fields in `acclient.h` contain tooltip +timestamps but no timer collection, and the named execution path is explicit. +The exact per-frame method is `UIElementManager::UseTime @ 0x0045CFD0`: + +```text +UseTime(): + CleanDeleteQueue() + ProcessUIMessageRemovalData() + if mouse hit-test requested: + DoMouseUpdate() + CheckTooltip() + BroadcastGlobalMessage(3, 0) + if ICIDM exists: + ICIDM.UseTime() + DrawDirtyRegions() +``` + +Global message `3` is the shared time pulse used by text caret/download work and +button hot-click repeat. Subscription and unsubscription are the cancellation +mechanism. Deletions and deferred listener removals are processed **before** the +time pulse. + +### 4.2 Tooltip deadlines + +Sources: manager constructor `0x0045F5D0`, `CheckTooltip @ 0x0045B6E0`, +`StartHover @ 0x00459250`, `StopHover @ 0x00459280`, `SwitchMouseOver @ +0x0045B560`, `MouseMoveHandler @ 0x0045E710`, and `ReleaseMouseCapture @ +0x0045D2B0`. + +Defaults are **0.25 seconds** delay and **10 seconds** duration. The delay is a +user preference (`Misc.TooltipDelay`, allowed range 0..10 seconds); per-element +float property `0x50` overrides it. + +```text +MouseMoveHandler(...): + lastMouseMoveTime = Timer.local_time + refresh the element under the pointer and call SwitchMouseOver + continue drag processing + +ReleaseMouseCapture(owner): + if owner holds capture: + lastMouseMoveTime = Timer.local_time + decrement/release capture + +CheckTooltip(): + now = Timer.local_time + + if hover has not started AND mouse is inside the window: + if no element has mouse capture AND lastEntered exists: + delay = lastEntered.floatProperty(0x50) or manager default + if now >= lastMouseMoveTime + delay: + StartHover(currentMouseX, currentMouseY) + + if tooltip element exists: + if now - tooltipStart >= tooltipDuration: + queue tooltip element for deletion + tooltipElement = null + SwitchMouseOver(null) + +StartHover(x, y): + if hover has not started: + hoverStarted = lastEntered.MouseHover(x, y) + +StopHover(): + if hoverStarted: + hoverStarted = false + lastEntered?.MouseUnhover() + if tooltipElement exists: + queue it for deletion + tooltipElement = null +``` + +`SwitchMouseOver(old -> new)` calls `StopHover` before sending +`old.MouseOverTop(false)`, then sends `new.MouseOverTop(true)`. Capture prevents +a new tooltip deadline from starting. `UIElementManager::DeletingElement @ +0x0045E520` also clears capture/hover/drag/tooltip ownership and removes queued +element messages that target the deleted element. + +There is therefore no “remove a due timer before callback” retail rule to port +for tooltips. The faithful rule is owner-state cancellation plus subscription +teardown before the global time pulse. + +### 4.3 Focus transfer order + +Sources: `UIElementManager::SetFocusElement @ 0x0045B890`, +`UIElement::TakeFocus @ 0x004601E0`, `UIElement::RelinquishFocus @ +0x00461DE0`, and `UIElementManager::BroadcastElementMessage @ 0x0045AB40`. + +```text +SetFocusElement(newFocus): + oldFocus = focusElement + if oldFocus == newFocus: return + + if oldFocus exists: + BroadcastElementMessage(oldFocus, 0x2F, param1=0, param2=0) + oldFocus.UnregisterInputMaps() + + focusElement = newFocus + + if newFocus exists: + BroadcastElementMessage(newFocus, 0x2F, param1=1, param2=0) + newFocus.RegisterInputMaps(priority=0x0BB8) // 3000 + +TakeFocus(element): + root = element.GetRootElement() + if root is null: return false + set element's owns-focus marker + if root is active: + SetFocusElement(element) + becameGlobal = true + root.rememberedFocusDescendant = element + return becameGlobal + +RelinquishFocus(element): + root = element.GetRootElement() + if root exists: + root.rememberedFocusDescendant = null + clear element's owns-focus marker + if manager.focusElement == element: + SetFocusElement(null) + return true +``` + +Focus messages are synchronous. If a handler broadcasts another element message +while one is already broadcasting, `BroadcastElementMessage` appends it to a +FIFO list and drains that list before clearing the broadcasting flag. This +prevents recursive listener traversal while retaining deterministic order. + +Active-root changes are separate from text focus. `UIElement::Activate @ +0x00461C80` activates/registers the root and restores its remembered focus +descendant through `SetFocusElement`; `Deactivate @ 0x00461D50` broadcasts root +deactivation and clears global focus when the root owned it. + +### 4.4 Keyboard routing relative to focus + +`UIElementManager::KeyPressEvent @ 0x0045C300` uses this order when the debug +console is not intercepting input: + +```text +target = focusElement if present else activeElement +if target exists: + consumed = target.KeyDown(action, extent) + if not consumed: + BroadcastGlobalMessage(1, action) +else: + BroadcastGlobalMessage(1, action) + +DoVisibilityToggleAction(action) // still runs after the route above +``` + +That final visibility-toggle call is unconditional within the normal input path; +it is not gated by whether the focus/active target consumed `KeyDown`. + +--- + +## 5. Conformance vectors to carry into Wave 1 + +1. **Text defaults:** construct Type 12 with no interaction properties; assert + editable=false, selectable=false, and no mouse claim absent tooltip/base role. +2. **Orthogonal capabilities:** cover all four `(editable, selectable)` pairs. + Editable-only places the caret but does not drag-select; selectable-only + selects/copies but cut/paste/delete do not mutate. +3. **Focus teardown:** disabling the last active text capability while focused + relinquishes focus; disabling one while the other remains does not. +4. **Button states:** golden table for states `1/2/3/6/7/8/13`, captured release + outside, re-entry, toggle on/off, and missing-state fallback. +5. **Hot click:** immediate message `2`, initial delay `0x10`, repeat interval + `0x11`, pause/reset while pointer is outside, and cancellation on mouse-up, + property disable, and destruction. +6. **Layout:** one vector per raw mode on each individual edge; include odd and + even parent/child sizes, negative intermediate centered values, a zero-sized + original parent, mode-0 preservation after initialization, and nested parents. +7. **Time order:** deletion/removal before global tick `3`; tooltip check before + tick `3`; capture release restarts the tooltip idle deadline. +8. **Focus order:** old loss message -> old map removal -> pointer assignment -> + new gain message -> new map registration; nested focus messages drain FIFO. + +## Remaining uncertainties + +- The recovered symbols do not name button properties `0x0B..0x13`; this note + records exact observed behavior but intentionally does not assert canonical + property names for all of them. +- Mouse action `7` is the normal primary-button path in these methods. Alternate + action `0x0A` is accepted by `UIElement_Button`; its user-facing input name was + not recovered in this bounded pass. +- The high text selection-direction bit used in `SetCursorPosition` is outside + `UIText_Flag` and remains unnamed. +- This pass did not reconstruct every `ICIDM` input action name, only the exact + routing and behavior needed to separate display/selectable/editable text. diff --git a/docs/research/retail-ui/00-master-synthesis.md b/docs/research/retail-ui/00-master-synthesis.md index 0dbbf771..31291547 100644 --- a/docs/research/retail-ui/00-master-synthesis.md +++ b/docs/research/retail-ui/00-master-synthesis.md @@ -1,5 +1,11 @@ # Retail AC Client GUI — Master Synthesis +> **Superseded timer interpretation (2026-07-10):** references below to a +> generic Device timer queue or `RegisterTimerEvent` are retained as research +> history, not an implementation oracle. Named-retail bodies instead prove +> tooltip-deadline polling followed by global UI time message `3`; see +> `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md`. + > **Scope note (2026-04-24, updated 2026-04-25):** This document > describes retail's Keystone UI toolkit — it is the research foundation > for **Phase D.2b (custom retail-look backend)**, not Phase D.2a diff --git a/docs/research/retail-ui/04-input-events.md b/docs/research/retail-ui/04-input-events.md index 0afc3995..3c8130ac 100644 --- a/docs/research/retail-ui/04-input-events.md +++ b/docs/research/retail-ui/04-input-events.md @@ -1,5 +1,12 @@ # Retail AC Client — UI Input Routing and Event System +> **Superseded timer interpretation (2026-07-10):** keep this document as +> historical research, but do not implement its inferred generic +> `Device::RegisterTimerEvent` queue. The named-retail bodies show +> `UIElementManager::UseTime` polling the hover tooltip deadline and then +> broadcasting global message `3`. The corrected oracle is +> `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md`. + **Scope:** How Win32 WndProc, mouse, keyboard, and drag-drop are routed through the retail AC client's UI tree, down to individual widgets, and how modality, focus, hover, and hotkeys are implemented. diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index c0ceec98..0158a40d 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -43,9 +43,9 @@ when the named decomp lacks a body. already contains the retail node. Runtime-created nodes are allowed only when retail creates them dynamically and the code cites that method. 6. **Typed behavior, not stringly behavior.** State names remain DAT data, but C# - policy uses enums/records (`UiTextInteractionMode`, button phase, cursor role, - shortcut kind, window event). Controllers must not implement precedence by - scattered state-name string lists. + policy uses typed capabilities/records (independent text `Editable` and + `Selectable` flags, button phase, cursor role, shortcut kind, window event). + Controllers must not implement precedence by scattered state-name string lists. 7. **Controllers have lifetimes.** Every subscribing controller implements `IDisposable`; subscriptions use removable named handlers or a small `SubscriptionBag`. `RetailUiRuntime.Dispose` tears down the complete tree. @@ -106,32 +106,32 @@ This wave changes no runtime behavior. ### 0.1 Consolidate missing retail pseudocode -- [ ] Write focused pseudocode notes for: +- [x] Write focused pseudocode notes for: - `UIElement_Text` interaction/property modes; - `UIElement_Button::UpdateState_`, mouse transitions, enabled/toggle behavior; - `UIElement::UpdateForParentSizeChange` raw edge modes 0-4; - - Device timer delivery and focus notifications; + - UI manager tooltip deadlines, global time delivery, and focus notifications; - `gmToolbarUI` global messages, drop branches, mana/stack behavior; - `ItemHolder::UseObject` / `AttemptPlaceIn3D` decision trees; - chat maximize, alpha, tabs, talk-focus and squelch; - paperdoll priority, Aetheria, hit testing and race updates; - character titles, luminance, and raise acknowledgement. -- [ ] Correct the roadmap premise around shortcuts: `ShortCutData` is +- [x] Correct the roadmap premise around shortcuts: `ShortCutData` is `index_`, `objectID_`, `spellID_`, while `PlayerModule::favorite_spells_[8]` is a separate subsystem. This retail toolbar restores only nonzero `objectID_`; it must not grow invented spell-slot behavior. -- [ ] Pin every method address and DAT property id before implementation. +- [x] Pin every method address and DAT property id before implementation. ### 0.2 Expand committed DAT conformance fixtures -- [ ] Add skip-by-default generators and committed `ElementInfo` JSON fixtures for +- [x] Add skip-by-default generators and committed `ElementInfo` JSON fixtures for toolbar `0x21000016`, inventory `0x21000023` plus mounted sublayouts, paperdoll `0x21000024`, and character `0x2100002E`. -- [ ] Include state media, cursor media, font ids, raw edge modes, default state, +- [x] Include state media, cursor media, font ids, raw edge modes, default state, min/max attributes, numeric state ids, and runtime-relevant scalar/property arrays. -- [ ] Add inheritance cases proving derived explicit `false` and `0` override base +- [x] Add inheritance cases proving derived explicit `false` and `0` override base values rather than being mistaken for absence. -- [ ] Add fixture assertions for every required controller role. +- [x] Add fixture assertions for every required controller role. ### 0.3 Repair the audit trail @@ -148,34 +148,35 @@ This wave changes no runtime behavior. ## 4. Wave 1 -- exact retained-widget foundation -### 1.1 Give Type-12 text an explicit interaction mode +### 1.1 Give Type-12 text independent retail capabilities -- [ ] Add `UiTextInteractionMode { Display, Selectable, Editable }`; default to - `Display` for factory-imported DAT text. -- [ ] Import retail Editable `0x16`, OneLine `0x20`, and Selectable `0x27` properties +- [x] Default factory-imported DAT text to display-only behavior. Model Editable + `0x16` and Selectable `0x27` as independent capabilities, matching retail; do + not collapse them into an exclusive interaction-mode enum. +- [x] Import retail Editable `0x16`, OneLine `0x20`, and Selectable `0x27` properties through the typed property bag where the DAT supplies them. -- [ ] Make focus, key capture, selection, clipboard, and pointer-drag behavior derive - exclusively from this mode. -- [ ] Set the chat transcript to `Selectable`; keep the chat input explicitly +- [x] Make focus, key capture, selection, clipboard, and pointer-drag behavior derive + exclusively from those capabilities. +- [x] Set the chat transcript to `Selectable`; keep the chat input explicitly `Editable`; all labels remain `Display`. -- [ ] Bind the imported vitals labels `0x100000EB/ED/EF` directly and delete the +- [x] Bind the imported vitals labels `0x100000EB/ED/EF` directly and delete the synthesized duplicates. -- [ ] Add tests proving display labels cannot steal focus, capture, or window drag. +- [x] Add tests proving display labels cannot steal focus, capture, or window drag. **Oracle:** `UIElement_Text::OnSetAttribute @ 0x0046A640`, mouse/key methods `0x00469370..0x0046A4A0`, `gmVitalsUI::PostInit @ 0x004BFCE0`. ### 1.2 Port the button state machine -- [ ] Extract a GL-free `UiButtonStateMachine` over enabled/hovered/pressed/ +- [x] Extract a GL-free `UiButtonStateMachine` over enabled/hovered/pressed/ captured/toggled state and available DAT states. -- [ ] Port retail transition precedence exactly; controller code supplies semantic +- [x] Port retail transition precedence exactly; controller code supplies semantic click/toggle callbacks only. -- [ ] Preserve retail state ids `1/2/3`, selected `6/7/8`, and ghosted `13`; if the +- [x] Preserve retail state ids `1/2/3`, selected `6/7/8`, and ghosted `13`; if the requested state is absent, retain the current custom semantic state rather than overwriting states such as `Maximized`, `Minimized`, or radar lock. -- [ ] Make cursor media follow the active button state. -- [ ] Cover normal, rollover, pressed, released-outside, disabled, toggle-on/off, +- [x] Make cursor media follow the active button state. +- [x] Cover normal, rollover, pressed, released-outside, disabled, toggle-on/off, and capture-loss tests using real fixture state names. **Oracle:** `UIElement_Button::UpdateState_ @ 0x00471CF0`, `MouseDown @@ -183,33 +184,35 @@ This wave changes no runtime behavior. ### 1.3 Replace lossy anchors with the retail layout policy -- [ ] Introduce `UiLayoutPolicy` containing the five raw edge modes `0..4`, original child +- [x] Introduce `UiLayoutPolicy` containing the five raw edge modes `0..4`, original child rect, and original parent rect. Preserve raw modes through `ElementReader`. -- [ ] Port `UpdateForParentSizeChange` as a pure function, including right-track, +- [x] Port `UpdateForParentSizeChange` as a pure function, including right-track, center mode `3`, proportional mode `4`, and inclusive-pixel behavior for all four edges. -- [ ] Keep `AnchorEdges` only as a compatibility adapter for programmatic widgets; +- [x] Keep `AnchorEdges` only as a compatibility adapter for programmatic widgets; imported DAT elements use `UiLayoutPolicy` directly. -- [ ] Test every raw mode against hand-translated decomp vectors at 800x600, +- [x] Test every raw mode against hand-translated decomp vectors at 800x600, widescreen, parent grow/shrink, and nested layouts. **Oracle:** `UIElement::UpdateForParentSizeChange @ 0x00462640`. -### 1.4 Finish Device-level timing/focus behavior +### 1.4 Finish UI-manager timing/focus behavior -- [ ] Drain registered timers from `UiRoot.Tick` in deterministic due-time/order. -- [ ] Remove a timer before dispatch for reentrancy safety and cancel subtree timers - when a window/widget is removed so stale callbacks cannot fire. -- [ ] Add typed focus/capture/window events; eliminate polling where retail reacts to +- [x] Poll the hovered element's tooltip deadline in `UiRoot.Tick`, then broadcast + retail global time message `3` to listeners in deterministic tree order. +- [x] Tear down focus, capture, modal ownership, hover, drag, default input, and + window registration before a removed subtree can receive another time/input pulse. +- [x] Add typed focus/capture transition behavior; eliminate polling where retail reacts to a transition. -- [ ] Pin double-click, tooltip, capture-loss, modal, and timer ordering tests. +- [x] Pin tooltip-before-global-time, capture-loss, modal, and subtree-removal + ordering tests. Keep double-click coverage with the existing input suite. ### 1.5 Add a narrow DAT named-state bridge -- [ ] Bind imported named states such as `ShowDetail`, `HideDetail`, enabled/ +- [x] Bind imported named states such as `ShowDetail`, `HideDetail`, enabled/ disabled button roles, and empty-cell art without globally re-registering Type 3 elements or teaching controllers how the importer stores states. -- [ ] Prove state changes preserve the imported rect, anchors, dimensions, and media +- [x] Prove state changes preserve the imported rect, anchors, dimensions, and media using production LayoutDesc fixtures. **Gate:** vitals/chat/radar/toolbar UI Studio fixtures unchanged visually; buttons @@ -415,14 +418,19 @@ mana selections; full suite and user visual gate. bottom pinning, and restore behavior on the **outer frame**. - [ ] Drive alpha from focus/lifecycle events and retail timing, not a fixed `0.75`. -### 5.4 Implement tabs and filters +### 5.4 Confirm the retail chat surface, then port its filters +The named `gmMainChatUI` build proves independently filtered chat/floaty windows; +it does **not** prove that DAT elements `0x10000522..0x10000525` are an in-window +numbered-tab model. Do not implement tab switching from the older inference. + +- [ ] Confirm the role of `0x10000522..0x10000525` from live retail behavior or a + stronger DAT/call-site trace before assigning tab semantics. - [ ] Add a Core/VM chat-line classification carrying retail message type/channel. -- [ ] Implement numbered tab models, active tab, display masks, unread state and - retail-backed persistence if the PlayerModule/options payload proves it. If the - masks are not persisted by this build, register that fact instead of inventing a - per-character format. -- [ ] Route each transcript through its active filter without duplicating ChatLog. +- [ ] Port the proven per-window display masks, unread state, and persistence only + where the PlayerModule/options payload proves it; otherwise register the absence. +- [ ] Route every retained/floating transcript through its filter without + duplicating `ChatLog`. ### 5.5 Complete talk-focus and moderation actions @@ -447,10 +455,12 @@ visual gate. ## 9. Wave 6 -- inventory completion -- [ ] On inventory `Hidden/Closed`, have the controller send - `NoLongerViewingContents` exactly once for the retail-eligible side/external - container, clear the viewed-container and panel drag state, and restore main-pack - presentation. Main-pack close sends nothing; repeated hiding is idempotent. +- [ ] Split the owned inventory window from the external/ground-container window. + The proven `ClientUISystem.groundObject` lifecycle sends + `NoLongerViewingContents` exactly once when an external view is replaced or + closed. Owned side-bag navigation/closing sends nothing unless a live packet + trace proves otherwise. Both lifecycles clear their own panel drag/view state; + repeated hide is idempotent. - [ ] Route grid/bag clicks through target mode and global `SelectionState`. - [ ] Retire capacity/burden fallbacks only after wire captures prove `ContainersCapacity`, `ItemsCapacity`, `EncumbranceVal`, and augmentation delivery. @@ -516,12 +526,14 @@ user visual gate. ### 8.3 Reconcile stat raises -- [ ] Trace whether retail waits for authoritative state or predicts a raise before - choosing the implementation. If it waits, remove optimistic mutation. If it - predicts, add per-target pending snapshots for XP, credits, ranks, and advancement - class, then confirm/rollback on the exact retail/ACE paths. -- [ ] Test multiple in-flight raises, reordered echoes, rejection, caps, and relog/ - disconnect cleanup without retry loops. +- [ ] Remove `ApplyLocalRaise`: named retail sends one request, ghosts the clicked + button, and leaves XP/credits/ranks unchanged until the authoritative quality + update arrives. +- [ ] Permit only one raise request in flight; clear the awaiting/ghosted state on + the matching authoritative quality-change path. Pin the exact rejection cleanup + with a live ACE trace before adding any extra timeout or recovery behavior. +- [ ] Test one-in-flight enforcement, authoritative acknowledgement, rejection, + caps, and relog/disconnect cleanup without optimistic mutation or retry loops. **Oracles:** `gmStatManagementUI`, `gmAttributeUI`, `gmSkillUI`, `gmCharacterTitleUI @ 0x0049A610`, `UpdateExperience @ 0x004F0A70`. @@ -584,7 +596,7 @@ scenario, UI Studio fixture, live user visual gate, divergence cleanup. | Imported display text steals focus/drag; duplicate vitals labels | 1 | | Buttons lack automatic retail states | 1 | | Edge mode 3 recenter and mode 4 policy lost | 1 | -| Device timers never fire | 1 | +| Tooltip/global UI time ordering and removed-subtree cleanup | 1 | | Chat maximize changes content, not outer frame | 2 / 5 | | Only radar persists position; windows lack lifecycle | 2 | | Controller subscriptions are not disposed | 2 | @@ -599,14 +611,14 @@ scenario, UI Studio fixture, live user visual gate, divergence cleanup. | Mana/stack selected-object behavior missing | 4 | | Vitals detail state missing | 5 | | Duplicate ChatVM lacks `/loc` and `/framerate` providers | 5 | -| Chat tabs, opacity, channel availability, squelch/actions incomplete | 5 | +| Chat filter windows, opacity, channel availability, squelch/actions incomplete; numbered-tab premise unproven | 5 | | Chat scrollbar arrows reversed | 2 / 5 | -| Inventory close skips `NoLongerViewingContents` | 2 / 6 | +| External-container close lifecycle missing; owned-side-bag `0x0195` premise disproved | 2 / 6 | | Inventory capacity/burden/drag residuals | 6 | | Paperdoll Aetheria, priority, legality, dual wield, body interactions | 7 | | Paperdoll empty art and per-race behavior | 7 | | Character Titles inert, live title/luminance missing | 8 | -| Character raise authoritative/prediction semantics are unreconciled | 8 | +| Character raises mutate optimistically instead of using proven server-authoritative flow | 8 | | Radar fellowship/allegiance state missing | 9 | | Combat/powerbar/spellbook/effects/social residuals/floating chat/quests/map/vitae/options/smartbox/vendor/trade/salvage/tinkering/world HUD absent | 10 | | Stale/missing/colliding divergence rows | 0, then every wave | diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md index 342ed53d..3fa5cc84 100644 --- a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md +++ b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md @@ -1,5 +1,12 @@ # D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design +> **Oracle correction (2026-07-10):** this historical design inferred that DAT +> elements `0x10000522..0x10000525` are numbered in-window chat tabs. The named +> `gmMainChatUI` call graph proves separate filtered chat/floaty windows but does +> not prove that tab model. Do not implement the inferred tab behavior without +> live-retail or stronger DAT/call-site confirmation. See +> `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. + **Date:** 2026-06-15 **Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main) **Status:** design — approved scope, pending spec review diff --git a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md index 9d32c455..7382ab8c 100644 --- a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md +++ b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md @@ -1,5 +1,13 @@ # D.2b inventory container-switching — design +> **Oracle correction (2026-07-10):** this historical design assigned +> `NoLongerViewingContents 0x0195` to owned side-bag navigation. Named retail +> instead sends it from the external `ClientUISystem.groundObject` lifecycle; +> the owned `gmInventoryUI` path does not statically prove such a send. Preserve +> this document as implementation history, but do not extend that packet behavior +> without a live trace. See +> `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. + **Date:** 2026-06-22 **Branch:** `claude/hopeful-maxwell-214a12` **Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip).