docs(ui): pin retained widget retail oracles

This commit is contained in:
Erik 2026-07-10 17:55:16 +02:00
parent 5931a820ea
commit 44f9ec13d9
10 changed files with 2753 additions and 73 deletions

View file

@ -1576,22 +1576,22 @@ R5/R6 touches the action list. **Where:**
--- ---
## #196Inventory window close never notifies the controller (NoLongerViewingContents skipped) ## #196External-container lifecycle and retained window events are missing
**Status:** OPEN **Status:** OPEN
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-02 **Filed:** 2026-07-02
**Component:** ui / net **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`.
--- ---
## #199Character raise flow: determine and port authoritative/predicted reconciliation ## #199Port the server-authoritative character raise flow
**Status:** OPEN **Status:** OPEN
**Severity:** MEDIUM **Severity:** MEDIUM
**Filed:** 2026-07-02 **Filed:** 2026-07-02
**Component:** ui / core **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`). **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.
--- ---

View file

@ -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 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 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:309573309597 | | 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:309573309597 |
| 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-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-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 | | 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-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-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-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-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-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` | | 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-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-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-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-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-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` | | 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-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-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-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-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-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) | | 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) |

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +1,11 @@
# Retail AC Client GUI — Master Synthesis # 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 > **Scope note (2026-04-24, updated 2026-04-25):** This document
> describes retail's Keystone UI toolkit — it is the research foundation > describes retail's Keystone UI toolkit — it is the research foundation
> for **Phase D.2b (custom retail-look backend)**, not Phase D.2a > for **Phase D.2b (custom retail-look backend)**, not Phase D.2a

View file

@ -1,5 +1,12 @@
# Retail AC Client — UI Input Routing and Event System # 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 **Scope:** How Win32 WndProc, mouse, keyboard, and drag-drop are routed
through the retail AC client's UI tree, down to individual widgets, and through the retail AC client's UI tree, down to individual widgets, and
how modality, focus, hover, and hotkeys are implemented. how modality, focus, hover, and hotkeys are implemented.

View file

@ -43,9 +43,9 @@ when the named decomp lacks a body.
already contains the retail node. Runtime-created nodes are allowed only when already contains the retail node. Runtime-created nodes are allowed only when
retail creates them dynamically and the code cites that method. retail creates them dynamically and the code cites that method.
6. **Typed behavior, not stringly behavior.** State names remain DAT data, but C# 6. **Typed behavior, not stringly behavior.** State names remain DAT data, but C#
policy uses enums/records (`UiTextInteractionMode`, button phase, cursor role, policy uses typed capabilities/records (independent text `Editable` and
shortcut kind, window event). Controllers must not implement precedence by `Selectable` flags, button phase, cursor role, shortcut kind, window event).
scattered state-name string lists. Controllers must not implement precedence by scattered state-name string lists.
7. **Controllers have lifetimes.** Every subscribing controller implements 7. **Controllers have lifetimes.** Every subscribing controller implements
`IDisposable`; subscriptions use removable named handlers or a small `IDisposable`; subscriptions use removable named handlers or a small
`SubscriptionBag`. `RetailUiRuntime.Dispose` tears down the complete tree. `SubscriptionBag`. `RetailUiRuntime.Dispose` tears down the complete tree.
@ -106,32 +106,32 @@ This wave changes no runtime behavior.
### 0.1 Consolidate missing retail pseudocode ### 0.1 Consolidate missing retail pseudocode
- [ ] Write focused pseudocode notes for: - [x] Write focused pseudocode notes for:
- `UIElement_Text` interaction/property modes; - `UIElement_Text` interaction/property modes;
- `UIElement_Button::UpdateState_`, mouse transitions, enabled/toggle behavior; - `UIElement_Button::UpdateState_`, mouse transitions, enabled/toggle behavior;
- `UIElement::UpdateForParentSizeChange` raw edge modes 0-4; - `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; - `gmToolbarUI` global messages, drop branches, mana/stack behavior;
- `ItemHolder::UseObject` / `AttemptPlaceIn3D` decision trees; - `ItemHolder::UseObject` / `AttemptPlaceIn3D` decision trees;
- chat maximize, alpha, tabs, talk-focus and squelch; - chat maximize, alpha, tabs, talk-focus and squelch;
- paperdoll priority, Aetheria, hit testing and race updates; - paperdoll priority, Aetheria, hit testing and race updates;
- character titles, luminance, and raise acknowledgement. - 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]` `index_`, `objectID_`, `spellID_`, while `PlayerModule::favorite_spells_[8]`
is a separate subsystem. This retail toolbar restores only nonzero is a separate subsystem. This retail toolbar restores only nonzero
`objectID_`; it must not grow invented spell-slot behavior. `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 ### 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, toolbar `0x21000016`, inventory `0x21000023` plus mounted sublayouts,
paperdoll `0x21000024`, and character `0x2100002E`. 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. 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. 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 ### 0.3 Repair the audit trail
@ -148,34 +148,35 @@ This wave changes no runtime behavior.
## 4. Wave 1 -- exact retained-widget foundation ## 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 - [x] Default factory-imported DAT text to display-only behavior. Model Editable
`Display` for factory-imported DAT text. `0x16` and Selectable `0x27` as independent capabilities, matching retail; do
- [ ] Import retail Editable `0x16`, OneLine `0x20`, and Selectable `0x27` properties 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. through the typed property bag where the DAT supplies them.
- [ ] Make focus, key capture, selection, clipboard, and pointer-drag behavior derive - [x] Make focus, key capture, selection, clipboard, and pointer-drag behavior derive
exclusively from this mode. exclusively from those capabilities.
- [ ] Set the chat transcript to `Selectable`; keep the chat input explicitly - [x] Set the chat transcript to `Selectable`; keep the chat input explicitly
`Editable`; all labels remain `Display`. `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. 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 **Oracle:** `UIElement_Text::OnSetAttribute @ 0x0046A640`, mouse/key methods
`0x00469370..0x0046A4A0`, `gmVitalsUI::PostInit @ 0x004BFCE0`. `0x00469370..0x0046A4A0`, `gmVitalsUI::PostInit @ 0x004BFCE0`.
### 1.2 Port the button state machine ### 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. 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. 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 requested state is absent, retain the current custom semantic state rather than
overwriting states such as `Maximized`, `Minimized`, or radar lock. overwriting states such as `Maximized`, `Minimized`, or radar lock.
- [ ] Make cursor media follow the active button state. - [x] Make cursor media follow the active button state.
- [ ] Cover normal, rollover, pressed, released-outside, disabled, toggle-on/off, - [x] Cover normal, rollover, pressed, released-outside, disabled, toggle-on/off,
and capture-loss tests using real fixture state names. and capture-loss tests using real fixture state names.
**Oracle:** `UIElement_Button::UpdateState_ @ 0x00471CF0`, `MouseDown **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 ### 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`. 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 center mode `3`, proportional mode `4`, and inclusive-pixel behavior for all four
edges. 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. 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. widescreen, parent grow/shrink, and nested layouts.
**Oracle:** `UIElement::UpdateForParentSizeChange @ 0x00462640`. **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. - [x] Poll the hovered element's tooltip deadline in `UiRoot.Tick`, then broadcast
- [ ] Remove a timer before dispatch for reentrancy safety and cancel subtree timers retail global time message `3` to listeners in deterministic tree order.
when a window/widget is removed so stale callbacks cannot fire. - [x] Tear down focus, capture, modal ownership, hover, drag, default input, and
- [ ] Add typed focus/capture/window events; eliminate polling where retail reacts to 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. 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 ### 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 disabled button roles, and empty-cell art without globally re-registering Type 3
elements or teaching controllers how the importer stores states. 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. using production LayoutDesc fixtures.
**Gate:** vitals/chat/radar/toolbar UI Studio fixtures unchanged visually; buttons **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**. bottom pinning, and restore behavior on the **outer frame**.
- [ ] Drive alpha from focus/lifecycle events and retail timing, not a fixed `0.75`. - [ ] 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. - [ ] Add a Core/VM chat-line classification carrying retail message type/channel.
- [ ] Implement numbered tab models, active tab, display masks, unread state and - [ ] Port the proven per-window display masks, unread state, and persistence only
retail-backed persistence if the PlayerModule/options payload proves it. If the where the PlayerModule/options payload proves it; otherwise register the absence.
masks are not persisted by this build, register that fact instead of inventing a - [ ] Route every retained/floating transcript through its filter without
per-character format. duplicating `ChatLog`.
- [ ] Route each transcript through its active filter without duplicating ChatLog.
### 5.5 Complete talk-focus and moderation actions ### 5.5 Complete talk-focus and moderation actions
@ -447,10 +455,12 @@ visual gate.
## 9. Wave 6 -- inventory completion ## 9. Wave 6 -- inventory completion
- [ ] On inventory `Hidden/Closed`, have the controller send - [ ] Split the owned inventory window from the external/ground-container window.
`NoLongerViewingContents` exactly once for the retail-eligible side/external The proven `ClientUISystem.groundObject` lifecycle sends
container, clear the viewed-container and panel drag state, and restore main-pack `NoLongerViewingContents` exactly once when an external view is replaced or
presentation. Main-pack close sends nothing; repeated hiding is idempotent. 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`. - [ ] Route grid/bag clicks through target mode and global `SelectionState`.
- [ ] Retire capacity/burden fallbacks only after wire captures prove - [ ] Retire capacity/burden fallbacks only after wire captures prove
`ContainersCapacity`, `ItemsCapacity`, `EncumbranceVal`, and augmentation delivery. `ContainersCapacity`, `ItemsCapacity`, `EncumbranceVal`, and augmentation delivery.
@ -516,12 +526,14 @@ user visual gate.
### 8.3 Reconcile stat raises ### 8.3 Reconcile stat raises
- [ ] Trace whether retail waits for authoritative state or predicts a raise before - [ ] Remove `ApplyLocalRaise`: named retail sends one request, ghosts the clicked
choosing the implementation. If it waits, remove optimistic mutation. If it button, and leaves XP/credits/ranks unchanged until the authoritative quality
predicts, add per-target pending snapshots for XP, credits, ranks, and advancement update arrives.
class, then confirm/rollback on the exact retail/ACE paths. - [ ] Permit only one raise request in flight; clear the awaiting/ghosted state on
- [ ] Test multiple in-flight raises, reordered echoes, rejection, caps, and relog/ the matching authoritative quality-change path. Pin the exact rejection cleanup
disconnect cleanup without retry loops. 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`, **Oracles:** `gmStatManagementUI`, `gmAttributeUI`, `gmSkillUI`,
`gmCharacterTitleUI @ 0x0049A610`, `UpdateExperience @ 0x004F0A70`. `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 | | Imported display text steals focus/drag; duplicate vitals labels | 1 |
| Buttons lack automatic retail states | 1 | | Buttons lack automatic retail states | 1 |
| Edge mode 3 recenter and mode 4 policy lost | 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 | | Chat maximize changes content, not outer frame | 2 / 5 |
| Only radar persists position; windows lack lifecycle | 2 | | Only radar persists position; windows lack lifecycle | 2 |
| Controller subscriptions are not disposed | 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 | | Mana/stack selected-object behavior missing | 4 |
| Vitals detail state missing | 5 | | Vitals detail state missing | 5 |
| Duplicate ChatVM lacks `/loc` and `/framerate` providers | 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 | | 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 | | Inventory capacity/burden/drag residuals | 6 |
| Paperdoll Aetheria, priority, legality, dual wield, body interactions | 7 | | Paperdoll Aetheria, priority, legality, dual wield, body interactions | 7 |
| Paperdoll empty art and per-race behavior | 7 | | Paperdoll empty art and per-race behavior | 7 |
| Character Titles inert, live title/luminance missing | 8 | | 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 | | 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 | | 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 | | Stale/missing/colliding divergence rows | 0, then every wave |

View file

@ -1,5 +1,12 @@
# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design # 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 **Date:** 2026-06-15
**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main) **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 **Status:** design — approved scope, pending spec review

View file

@ -1,5 +1,13 @@
# D.2b inventory container-switching — design # 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 **Date:** 2026-06-22
**Branch:** `claude/hopeful-maxwell-214a12` **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). **Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip).