feat(ui): port retail dialog factory lifecycle

Replace the single mutable confirmation service with retail's property-backed DialogFactory model: fresh DAT roots, context ids, queue groups, priority preemption, callback/close-notice ordering, and context cancellation. Route /die, server confirmation aborts, and guarded item use through focused semantic owners.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-13 16:30:05 +02:00
parent 43f7c7807c
commit 66bdae7a83
22 changed files with 1442 additions and 220 deletions

View file

@ -70,7 +70,8 @@ fill-components. `ClientCommandController` keeps the panel layer independent
of App/network services; `WorldSession` sends the exact game-action or control of App/network services; `WorldSession` sends the exact game-action or control
message for server-backed families. Friends and squelch databases are parsed message for server-backed families. Friends and squelch databases are parsed
into authoritative Core state, confirmation requests use the imported retail into authoritative Core state, confirmation requests use the imported retail
dialog, and unknown `/` commands still publish `SendServerCommandCmd` in dialog catalog through the ported context/queue/callback factory, and unknown
`/` commands still publish `SendServerCommandCmd` in
canonical `@` form so ACE administrator commands continue to work. Both chat canonical `@` form so ACE administrator commands continue to work. Both chat
backends use the same router. backends use the same router.
@ -83,9 +84,16 @@ showing the live `FPS` and `DEG` values with the authored font and two-decimal
format. `/framrate` remains unknown because retail registers no misspelled format. `/framrate` remains unknown because retail registers no misspelled
alias. alias.
The reusable-dialog follow-up ports `DialogFactory @ 0x004773C0..0x00478470`
and type-1 `ConfirmationDialog` rather than sharing one mutable widget. Every
display gets a fresh LayoutDesc `0x2100003C` root; `/die`, server confirmation
tuples (including `CharacterConfirmationDone` aborts), and PK/NPK/volatile-rare
item use now share its property-backed result and lifecycle.
**Research:** **Research:**
`docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`;
`docs/research/2026-07-13-retail-client-command-families-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`;
`docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`;
`ClientCommunicationSystem::OnChatCommand @ 0x00581320`; `ClientCommunicationSystem::OnChatCommand @ 0x00581320`;
`ClientCommunicationSystem::DoLifestone @ 0x0056FC70`; `ClientCommunicationSystem::DoLifestone @ 0x0056FC70`;
`CM_Character::Event_TeleToLifestone @ 0x006A1B90`. `CM_Character::Event_TeleToLifestone @ 0x006A1B90`.

View file

@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, confirmation dialogs `0x2100003C`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |

View file

@ -501,7 +501,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. - **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets, and confirmation requests mount imported LayoutDesc `0x2100003C`. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.

View file

@ -495,6 +495,10 @@ include dungeons.
The first connected gate passed the family except for raw suicide success The first connected gate passed the family except for raw suicide success
code `0x004A` and the incomplete title-bar-only FPS toggle; the corrective code `0x004A` and the incomplete title-bar-only FPS toggle; the corrective
pass maps retail's success text and mounts the authored SmartBox FPS element. pass maps retail's success text and mounts the authored SmartBox FPS element.
Confirmation reuse now follows retail's `DialogFactory`: fresh DAT roots,
property-backed results, context ids, independent queues/priority, callbacks,
close notices, and server abort handling; `/die`, gameplay requests, and
guarded item uses share the type-1 presenter.
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c). - **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:** **Freeze on landing:**

View file

@ -0,0 +1,190 @@
# Retail dialog factory and confirmation lifecycle
Date: 2026-07-13
## Scope
This note pins the reusable retail dialog architecture needed by acdream's
confirmation call sites. It ports the factory/context/queue lifecycle and the
type-1 `ConfirmationDialog`. The other six catalog types are identified here
so the factory can accept their presenters later, but their type-specific data
contracts are outside this slice.
## Named retail sources
- `Dialog::UpdatePendingDialogDisplay @ 0x00476930`
- `Dialog::UpdateDialogText @ 0x00476A50`
- `Dialog::SetData @ 0x00476BE0`
- `DialogFactory::DialogDone @ 0x004773C0`
- `DialogFactory::UpdatePendingDialogDisplay_ @ 0x00477590`
- `DialogFactory::Reset @ 0x00477950`
- `DialogFactory::CreateDialog_ @ 0x00477AD0`
- `DialogFactory::OpenNextDialog @ 0x00477D30`
- `DialogFactory::MakeDialog @ 0x00477E90`
- `DialogFactory::CloseDialog @ 0x00478160`
- `DialogFactory::MakeCallbackDialogInCurrentUI @ 0x00478430`
- `ConfirmationDialog::SetData @ 0x004764D0`
- `ConfirmationDialog::ListenToElementMessage @ 0x00476670`
- `ConfirmationDialog::CancelDialog @ 0x00476770`
- `ClientCommunicationSystem::DoDie @ 0x00580050`
- `ClientCommunicationSystem::DieDialogCallback @ 0x0057BA70`
- `ClientUISystem::UsageCallback @ 0x00565B20`
- `ClientUISystem::UsageConfirmation_PKAltar @ 0x00566420`
- `ClientUISystem::UsageConfirmation_NPKAltar @ 0x00566610`
- `ClientUISystem::UsageConfirmation_VolatileRare @ 0x00566800`
- `ClientUISystem::Handle_Character__ConfirmationRequest @ 0x005640A0`
- `gmGamePlayUI::MakeGameplayConfirmationDialog @ 0x004EB890`
- `gmGamePlayUI::RecvNotice_CloseDialog @ 0x004EA4F0`
- `gmGamePlayUI::RecvNotice_AbortConfirmationRequest @ 0x004E9D70`
Verbatim `DialogInfo` layout: `docs/research/named-retail/acclient.h`, struct
5922. Protocol payloads were independently cross-checked against ACE and
ACClientLib during the command-family port; see
`docs/research/2026-07-13-retail-client-command-families-pseudocode.md`.
## Property contract
```text
0x8D boolean priority/preempt-current flag
0x8E integer dialog type
0x90 StringInfo optional accept caption
0x91 StringInfo optional reject caption
0x92 boolean confirmation result
0xAC boolean when true, Dialog::SetData sets UIElement attribute 0x40
0xC3 integer queue key; default 2, value 1 is nonqueued
0xC5 StringInfo main dialog text
0x1000003D instance item/object id carried by item confirmations
```
Dialog type to catalog-root/type mapping in `CreateDialog_`:
```text
1 -> root 0x15, element type 0x13, ConfirmationDialog
2 -> root 0x31, element type 0x19, WaitDialog
3 -> root 0x24, element type 0x17, MessageDialog
4 -> root 0x28, element type 0x18, TextInputDialog
5 -> root 0x2C, element type 0x15, ConfirmationTextInputDialog
6 -> root 0x1B, element type 0x16, MenuDialog
7 -> root 0x1F, element type 0x14, ConfirmationMenuDialog
```
## Factory pseudocode
```text
make_dialog(parent, input_data):
context = ++global_context
info = DialogInfo(copy(input_data), pointer=null, parent, context)
priority = input_data.bool(0x8D, false)
queue_key = input_data.uint(0xC3, 2)
if queue_key == 1:
append info to active_nonqueued
create_dialog(info, queue_key)
return context
if no active dialog for queue_key:
create_dialog(info, queue_key)
else if not priority:
pending[queue_key].push_back(info)
update_pending_displays()
else:
current = active[queue_key]
remove current root from parent without completing it
pending[queue_key].push_front(current)
create_dialog(info, queue_key)
return context
create_dialog(info, queue_key):
store info as active nonqueued or active[queue_key]
read type from property 0x8E
create a FRESH root element from shared catalog enum (2, 5)
assign context to the Dialog
dialog.SetData(info.data)
bring dialog to front
update_pending_displays()
broadcast OpenDialog(context)
update_pending_display(dialog, pending_count):
resolve child 0x33 and its text child 0x34 once
child_0x33.SetState(pending_count == 0 ? 0x19 : 0x18)
// retail does not synthesize numeric count text
close_dialog(context):
if context is active nonqueued:
dialog_done(info)
remove it
return
if context is an active queued dialog:
remove active[queue_key]
dialog_done(info)
open_next_dialog(queue_key)
return
if context is pending:
dialog_done(info)
remove it from pending
update_pending_displays()
dialog_done(info):
if callback exists for info.context:
callback(dialog.data)
remove callback
broadcast CloseDialog(info.context, dialog.data)
remove the root element from its parent
dialog.context = 0
open_next_dialog(queue_key):
if pending[queue_key] has an entry:
info = pop_front()
create_dialog(info, queue_key)
delete an empty pending queue
```
`Reset` runs `dialog_done` for active and pending dialogs before clearing all
factory collections and callbacks.
## Confirmation presenter pseudocode
```text
confirmation.SetData(data):
base.SetData(data) // copies collection; 0xC5 updates child 0x3E
if data has 0x90: set child 0x17 caption
if data has 0x91: set child 0x19 caption
on button message:
if child is not 0x17 or 0x19: delegate to base
data[0x92] = (child == 0x17)
DialogFactory.CloseDialog(context)
cancel:
data[0x92] = false
DialogFactory.CloseDialog(context)
```
The catalog/root/class are reused; the live widget instance is not. Retail
creates a fresh root for every displayed `DialogInfo`, which is required for
simultaneous nonqueued dialogs and independent queue groups.
## Integration ownership
```text
/die:
data = { 0x8E=1, 0xC5=retail message }
MakeCallbackDialogInCurrentUI(data, DieDialogCallback)
callback sends suicide only when 0x92 is true
item confirmation:
data = { 0x8E=1, 0xC5=message, 0x1000003D=item id }
MakeCallbackDialogInCurrentUI(data, UsageCallback)
callback sends Use only when 0x92 is true
server gameplay confirmation:
gameplay UI stores server type/context
data = { 0x8E=1, 0xC5=message }
context = MakeDialog(gameplay UI, data) // no direct callback
DialogFactory's CloseDialog notice reaches gmGamePlayUI
gmGamePlayUI reads 0x92 and sends ConfirmationResponse(type, server context, result)
matching ConfirmationDone closes the context through DialogFactory
```

View file

@ -2208,6 +2208,9 @@ public sealed class GameWindow : IDisposable
Cursor: new AcDream.App.UI.RetailUiCursorBindings( Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController, cursorFeedbackController,
retailCursorManager), retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) =>
_liveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity, StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry, Plugins: _uiRegistry,
Persistence: persistence, Persistence: persistence,
@ -2659,12 +2662,9 @@ public sealed class GameWindow : IDisposable
onUseDone: error => _itemInteractionController?.CompleteUse(error), onUseDone: error => _itemInteractionController?.CompleteUse(error),
itemMana: ItemMana, itemMana: ItemMana,
onConfirmationRequest: request => onConfirmationRequest: request =>
_retailUiRuntime?.ConfirmationDialogs?.Show( _retailUiRuntime?.HandleConfirmationRequest(request),
request.Message, onConfirmationDone: done =>
accepted => session.SendConfirmationResponse( _retailUiRuntime?.HandleConfirmationDone(done),
request.Type,
request.ContextId,
accepted)),
friends: Friends, friends: Friends,
squelch: Squelch, squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components); onDesiredComponents: components => DesiredComponents = components);
@ -2764,7 +2764,7 @@ public sealed class GameWindow : IDisposable
LastOutsideCorpsePosition: () => LastOutsideCorpsePosition: () =>
LocalPlayer.GetPosition(0x0Eu), LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) => ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ConfirmationDialogs?.Show(message, completed), _retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: liveSession.SendSuicide, Suicide: liveSession.SendSuicide,
ClearChat: _ => chat.Clear(), ClearChat: _ => chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),

View file

@ -0,0 +1,91 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
namespace AcDream.App.UI;
/// <summary>
/// Ports the server-driven confirmation ownership in
/// <c>gmGamePlayUI @ 0x004E9D70..0x004E9EB6</c>. The dialog factory broadcasts
/// completion; this semantic owner retains the server type/context and sends the
/// matching confirmation response.
/// </summary>
public sealed class GameplayConfirmationController : IDisposable
{
private readonly RetailDialogFactory _dialogs;
private readonly Action<uint, uint, bool> _sendResponse;
private uint _dialogContext;
private uint _serverType;
private uint _serverContext;
private bool _disposed;
public GameplayConfirmationController(
RetailDialogFactory dialogs,
Action<uint, uint, bool> sendResponse)
{
_dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
_sendResponse = sendResponse ?? throw new ArgumentNullException(nameof(sendResponse));
_dialogs.DialogClosed += OnDialogClosed;
}
public uint ActiveDialogContext => _dialogContext;
public bool HandleRequest(GameEvents.CharacterConfirmationRequest request)
{
// ClientUISystem::Handle_Character__ConfirmationRequest @ 0x005640A0
// routes types 2/3/5/6 to handlers that append " Continue?"; the generic
// type-7 YesNo request preserves the server text verbatim. Types 1 and 4
// have allegiance/fellowship semantic owners but use the same response
// tuple, so this controller retains that tuple until those panels exist.
// Each retail RecvNotice_* handler stores the tuple before calling the
// shared maker, including the already-open case.
_serverType = request.Type;
_serverContext = request.ContextId;
// gmGamePlayUI::MakeGameplayConfirmationDialog @ 0x004EB890 refuses a
// second gameplay confirmation while its context is non-zero.
if (_dialogContext != 0u)
return false;
string message = request.Type is 2u or 3u or 5u or 6u
? request.Message + " Continue?"
: request.Message;
var data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.ElementAttribute40, true);
_dialogContext = _dialogs.MakeDialog(data);
return _dialogContext != 0u;
}
public bool HandleDone(GameEvents.CharacterConfirmationDone done)
{
// gmGamePlayUI::RecvNotice_AbortConfirmationRequest @ 0x004E9D70
// matches both server fields before closing the local dialog context.
if (_dialogContext == 0u
|| done.Type != _serverType
|| done.ContextId != _serverContext)
return false;
return _dialogs.CloseDialog(_dialogContext);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_dialogs.DialogClosed -= OnDialogClosed;
}
private void OnDialogClosed(uint context, RetailDialogData data)
{
if (context != _dialogContext
|| data.GetUInt32(RetailDialogProperty.Type) !=
(uint)RetailDialogType.Confirmation)
return;
bool accepted = data.GetBoolean(RetailDialogProperty.ConfirmationResult);
// gmGamePlayUI::CloseGameplayConfirmationDialog @ 0x004E9E80 sends
// before clearing its retained server/dialog context fields.
_sendResponse(_serverType, _serverContext, accepted);
_dialogContext = 0u;
_serverType = 0u;
_serverContext = 0u;
}
}

View file

@ -288,6 +288,21 @@ public sealed class ItemInteractionController : IDisposable
public bool AcquireSelfTarget() public bool AcquireSelfTarget()
=> IsTargetModeActive && AcquireTarget(_playerGuid()); => IsTargetModeActive && AcquireTarget(_playerGuid());
/// <summary>
/// Completes the positive branch of retail
/// <c>ClientUISystem::UsageCallback @ 0x00565B20</c>. Confirmation policy has
/// already consumed the original activation, so acceptance sends the retained
/// object id and enters the same busy state as an ordinary use.
/// </summary>
public bool ExecuteConfirmedUse(uint objectId)
{
if (objectId == 0u || _sendUse is null)
return false;
_sendUse(objectId);
_busyCount++;
return true;
}
public void CancelTargetMode() public void CancelTargetMode()
{ {
if (!IsAnyTargetModeActive) return; if (!IsAnyTargetModeActive) return;

View file

@ -1,122 +0,0 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained implementation of retail <c>DialogFactory</c>'s confirmation queue.
/// The visual tree is element <c>0x15</c> from the shared dialog catalog resolved by
/// <c>GetDIDByEnum(2, 5)</c>; callbacks mirror property <c>0x92</c> from
/// <c>ConfirmationDialog::ListenToElementMessage @ 0x00476670</c>.
/// </summary>
public sealed class RetailConfirmationDialogService
{
public const uint RootElementId = 0x15u;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
public const uint AcceptButtonId = 0x17u;
public const uint RejectButtonId = 0x19u;
private sealed record Request(string Message, Action<bool> Completed);
private readonly UiRoot _host;
private readonly UiDialogRoot _dialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiButton _accept;
private readonly UiButton _reject;
private readonly Queue<Request> _queue = new();
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
private Request? _current;
public RetailConfirmationDialogService(UiRoot host, ImportedLayout layout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_dialog = layout.Root as UiDialogRoot
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout));
_basePopupHeight = _popup.Height;
_baseMessageHeight = _message.Height;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_message.LayoutPolicy = null;
_message.Anchors = AnchorEdges.None;
_message.Padding = 0f;
_message.Selectable = false;
_dialog.Cancel = () => Complete(false);
_accept.OnClick = () => Complete(true);
_reject.OnClick = () => Complete(false);
}
public bool IsOpen => _current is not null;
public int PendingCount => _queue.Count;
public void Show(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
ArgumentNullException.ThrowIfNull(completed);
_queue.Enqueue(new Request(message, completed));
if (_current is null)
ShowNext();
}
public void Tick()
{
if (_current is null) return;
SizeAndCenter();
}
private void ShowNext()
{
if (_queue.Count == 0) return;
_current = _queue.Dequeue();
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static text => text.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(
_current.Message,
measure,
maximumWidth);
var lines = new UiText.Line[wrapped.Count];
for (int i = 0; i < wrapped.Count; i++)
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
_message.LinesProvider = () => lines;
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
SizeAndCenter();
_host.AddChild(_dialog);
_host.Modal = _dialog;
}
private void SizeAndCenter()
{
_dialog.Left = 0f;
_dialog.Top = 0f;
_dialog.Width = _host.Width;
_dialog.Height = _host.Height;
_popup.Left = MathF.Round((_dialog.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((_dialog.Height - _popup.Height) * 0.5f);
}
private void Complete(bool accepted)
{
Request? completed = _current;
if (completed is null) return;
_current = null;
_host.RemoveChild(_dialog);
completed.Completed(accepted);
ShowNext();
}
}

View file

@ -0,0 +1,139 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// One live type-1 retail confirmation root. Instances are intentionally short-lived:
/// <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c> creates a fresh catalog root for
/// every displayed <c>DialogInfo</c>.
/// </summary>
internal sealed class RetailConfirmationDialogView
{
public const uint RootElementId = 0x15u;
public const uint AcceptButtonId = 0x17u;
public const uint RejectButtonId = 0x19u;
public const uint PendingDisplayId = 0x33u;
public const uint PendingDisplayTextId = 0x34u;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
private readonly UiRoot _host;
private readonly RetailDialogData _data;
private readonly uint _context;
private readonly Action<uint> _closeDialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiButton _accept;
private readonly UiButton _reject;
private readonly UiElement? _pendingDisplay;
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
public RetailConfirmationDialogView(
UiRoot host,
ImportedLayout layout,
RetailDialogData data,
uint context,
Action<uint> closeDialog)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_data = data ?? throw new ArgumentNullException(nameof(data));
_context = context;
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
Root = layout.Root as UiDialogRoot
?? throw new ArgumentException("Confirmation layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Confirmation layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Confirmation layout is missing text element 0x3E.", nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing accept button 0x17.", nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException("Confirmation layout is missing reject button 0x19.", nameof(layout));
_pendingDisplay = layout.FindElement(PendingDisplayId);
_basePopupHeight = _popup.Height;
_baseMessageHeight = _message.Height;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_message.LayoutPolicy = null;
_message.Anchors = AnchorEdges.None;
_message.Padding = 0f;
_message.Selectable = false;
if (_data.GetString(RetailDialogProperty.AcceptLabel) is { } acceptLabel)
_accept.Label = acceptLabel;
if (_data.GetString(RetailDialogProperty.RejectLabel) is { } rejectLabel)
_reject.Label = rejectLabel;
Root.Cancel = Reject;
_accept.OnClick = Accept;
_reject.OnClick = Reject;
SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
SizeAndCenter();
}
public UiDialogRoot Root { get; }
public RetailDialogData Data => _data;
public void Tick() => SizeAndCenter();
public void SetPendingCount(int count)
{
// Dialog::UpdatePendingDialogDisplay @ 0x00476930 resolves children
// 0x33/0x34 and calls SetState(0x19) for an empty queue or SetState(0x18)
// for a non-empty queue. Do not synthesize count text: retail does not.
if (_pendingDisplay is IUiDatStateful stateful)
stateful.TrySetRetailState(count == 0 ? 0x19u : 0x18u);
}
public void DetachHandlers()
{
Root.Cancel = null;
_accept.OnClick = null;
_reject.OnClick = null;
}
private void Accept()
{
// ConfirmationDialog::ListenToElementMessage @ 0x00476670.
_data.Set(RetailDialogProperty.ConfirmationResult, true);
_closeDialog(_context);
}
private void Reject()
{
// ConfirmationDialog::CancelDialog @ 0x00476770 uses the same false
// result for the reject button and Escape/cancel.
_data.Set(RetailDialogProperty.ConfirmationResult, false);
_closeDialog(_context);
}
private void SetMessage(string text)
{
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
var lines = new UiText.Line[wrapped.Count];
for (int i = 0; i < wrapped.Count; i++)
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
_message.LinesProvider = () => lines;
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
}
private void SizeAndCenter()
{
Root.Left = 0f;
Root.Top = 0f;
Root.Width = _host.Width;
Root.Height = _host.Height;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}
}

View file

@ -0,0 +1,113 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Property identifiers consumed by retail's <c>Dialog</c> and
/// <c>DialogFactory</c> implementations.
/// </summary>
public static class RetailDialogProperty
{
public const uint Priority = 0x8Du;
public const uint Type = 0x8Eu;
public const uint AcceptLabel = 0x90u;
public const uint RejectLabel = 0x91u;
public const uint ConfirmationResult = 0x92u;
/// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
/// </summary>
public const uint ElementAttribute40 = 0xACu;
public const uint QueueKey = 0xC3u;
public const uint Message = 0xC5u;
public const uint UsageObjectId = 0x1000003Du;
}
/// <summary>
/// Retail dialog types selected by property <c>0x8E</c> in
/// <c>DialogFactory::CreateDialog_ @ 0x00477AD0</c>.
/// </summary>
public enum RetailDialogType : uint
{
Confirmation = 1,
Wait = 2,
Message = 3,
TextInput = 4,
ConfirmationTextInput = 5,
Menu = 6,
ConfirmationMenu = 7,
}
/// <summary>
/// Modern typed carrier for retail's <c>PropertyCollection</c> dialog contract.
/// The raw numeric keys remain visible because type-specific presenters and semantic
/// callbacks both extend the same collection in retail.
/// </summary>
public sealed class RetailDialogData
{
private readonly Dictionary<uint, object> _values = new();
public IReadOnlyDictionary<uint, object> Values => _values;
public RetailDialogData Set<T>(uint propertyId, T value) where T : notnull
{
_values[propertyId] = value;
return this;
}
public bool Contains(uint propertyId) => _values.ContainsKey(propertyId);
public bool TryGet<T>(uint propertyId, out T value)
{
if (_values.TryGetValue(propertyId, out object? raw) && raw is T typed)
{
value = typed;
return true;
}
value = default!;
return false;
}
public bool GetBoolean(uint propertyId, bool defaultValue = false)
=> _values.TryGetValue(propertyId, out object? raw)
? raw switch
{
bool value => value,
byte value => value != 0,
int value => value != 0,
uint value => value != 0,
_ => defaultValue,
}
: defaultValue;
public uint GetUInt32(uint propertyId, uint defaultValue = 0u)
=> _values.TryGetValue(propertyId, out object? raw)
? raw switch
{
byte value => value,
ushort value => value,
int value when value >= 0 => (uint)value,
uint value => value,
Enum value => Convert.ToUInt32(value),
_ => defaultValue,
}
: defaultValue;
public string? GetString(uint propertyId)
=> _values.TryGetValue(propertyId, out object? raw) ? raw as string : null;
public RetailDialogData Clone()
{
var clone = new RetailDialogData();
foreach ((uint propertyId, object value) in _values)
clone._values.Add(propertyId, value);
return clone;
}
public static RetailDialogData Confirmation(string message)
{
ArgumentNullException.ThrowIfNull(message);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
.Set(RetailDialogProperty.Message, message);
}
}

View file

@ -0,0 +1,323 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retained-mode port of retail <c>DialogFactory @ 0x004773C0..0x00478470</c>.
/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs,
/// priority preemption, callback delivery, close notices, and fresh catalog roots.
/// </summary>
public sealed class RetailDialogFactory : IDisposable
{
public const uint DefaultQueueKey = 2u;
public const uint NonQueuedKey = 1u;
private sealed class DialogInfo
{
public required RetailDialogData Data { get; init; }
public required uint Context { get; init; }
public required uint QueueKey { get; init; }
public Action<RetailDialogData>? Callback { get; init; }
public RetailConfirmationDialogView? View { get; set; }
}
private readonly UiRoot _host;
private readonly Func<RetailDialogType, ImportedLayout?> _createLayout;
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private bool _disposed;
public RetailDialogFactory(
UiRoot host,
Func<RetailDialogType, ImportedLayout?> createLayout)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
}
/// <summary>
/// Retail's global close-dialog notice, raised after the per-context callback and
/// before the live root is removed from its parent.
/// </summary>
public event Action<uint, RetailDialogData>? DialogClosed;
public event Action<uint>? DialogOpened;
public bool IsOpen => _activeQueued.Count != 0 || _activeNonQueued.Count != 0;
public int ActiveCount => _activeQueued.Count + _activeNonQueued.Count;
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
{
RetailDialogType.Confirmation => 0x15u,
RetailDialogType.Wait => 0x31u,
RetailDialogType.Message => 0x24u,
RetailDialogType.TextInput => 0x28u,
RetailDialogType.ConfirmationTextInput => 0x2Cu,
RetailDialogType.Menu => 0x1Bu,
RetailDialogType.ConfirmationMenu => 0x1Fu,
_ => 0u,
};
public uint MakeDialog(RetailDialogData data)
=> MakeDialog(data, callback: null);
/// <summary>
/// Retail <c>MakeCallbackDialogInCurrentUI @ 0x00478430</c>.
/// </summary>
public uint MakeDialog(RetailDialogData data, Action<RetailDialogData>? callback)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(data);
uint context = NextContext();
RetailDialogData ownedData = data.Clone();
uint queueKey = ownedData.GetUInt32(RetailDialogProperty.QueueKey, DefaultQueueKey);
if (queueKey == 0u)
queueKey = DefaultQueueKey;
var info = new DialogInfo
{
Data = ownedData,
Context = context,
QueueKey = queueKey,
Callback = callback,
};
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
_activeQueued.Add(queueKey, info);
CreateDialog(info);
return context;
}
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
{
queue.AddLast(info);
UpdatePendingDialogDisplays();
return context;
}
// MakeDialog's 0x8D branch removes the active root without completing the
// DialogInfo, inserts that current info at the front, then displays the
// priority request in its place.
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
return context;
}
public uint MakeConfirmation(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey,
bool priority = false)
{
RetailDialogData data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
if (priority)
data.Set(RetailDialogProperty.Priority, true);
return MakeDialog(data, callback);
}
/// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
/// </summary>
public bool CloseDialog(uint context)
{
if (context == 0u)
return false;
if (_activeNonQueued.Remove(context, out DialogInfo? nonQueued))
{
DialogDone(nonQueued);
return true;
}
foreach ((uint queueKey, DialogInfo active) in _activeQueued.ToArray())
{
if (active.Context != context)
continue;
_activeQueued.Remove(queueKey);
DialogDone(active);
OpenNextDialog(queueKey);
return true;
}
foreach ((uint queueKey, LinkedList<DialogInfo> queue) in _pending.ToArray())
{
LinkedListNode<DialogInfo>? node = queue.First;
while (node is not null && node.Value.Context != context)
node = node.Next;
if (node is null)
continue;
DialogInfo pending = node.Value;
queue.Remove(node);
if (queue.Count == 0)
_pending.Remove(queueKey);
DialogDone(pending);
UpdatePendingDialogDisplays();
return true;
}
return false;
}
public void Tick()
{
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
}
/// <summary>
/// Retail <c>DialogFactory::Reset @ 0x00477950</c> completes active and pending
/// infos before clearing the factory.
/// </summary>
public void Reset()
{
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Distinct()
.ToArray();
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
foreach (DialogInfo info in infos)
DialogDone(info);
RefreshModal();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Reset();
}
private uint NextContext()
{
_globalContext++;
if (_globalContext == 0u)
_globalContext++;
return _globalContext;
}
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
return queue;
queue = new LinkedList<DialogInfo>();
_pending.Add(queueKey, queue);
return queue;
}
private void CreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type != RetailDialogType.Confirmation)
throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
ImportedLayout layout = _createLayout(type)
?? throw new InvalidOperationException(
$"Retail dialog catalog could not create type {(uint)type}.");
var view = new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context));
info.View = view;
_host.AddChild(view.Root);
_host.BringToFront(view.Root);
_openOrder.Add(info);
_host.Modal = view.Root;
UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context);
}
private void Suspend(DialogInfo info)
{
if (info.View is null)
return;
RetailConfirmationDialogView view = info.View;
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
RefreshModal();
}
private void DialogDone(DialogInfo info)
{
// DialogDone @ 0x004773C0 delivers the registered callback first, then
// broadcasts the CloseDialog notice, and only then removes the root.
try
{
info.Callback?.Invoke(info.Data);
DialogClosed?.Invoke(info.Context, info.Data);
}
finally
{
RemoveView(info);
}
}
private void RemoveView(DialogInfo info)
{
if (info.View is { } view)
{
info.View = null;
view.DetachHandlers();
_openOrder.Remove(info);
_host.RemoveChild(view.Root);
}
RefreshModal();
}
private void OpenNextDialog(uint queueKey)
{
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
DialogInfo next = queue.First.Value;
queue.RemoveFirst();
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
}
private void UpdatePendingDialogDisplays()
{
foreach ((uint queueKey, DialogInfo active) in _activeQueued)
{
int count = _pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
? queue.Count
: 0;
active.View?.SetPendingCount(count);
}
}
private void RefreshModal()
{
_host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root;
}
}

View file

@ -0,0 +1,65 @@
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// Retained UI owner for the item-use confirmations emitted by
/// <see cref="ItemInteractionController"/>. Message/data construction mirrors
/// <c>ClientUISystem::UsageConfirmation_* @ 0x00566420..0x005669EA</c> and
/// completion mirrors <c>ClientUISystem::UsageCallback @ 0x00565B20</c>.
/// </summary>
public sealed class RetailItemConfirmationController : IDisposable
{
internal const string PlayerKillerMessage =
"Using this altar will make you a player killer, able to attack or be attacked by other player killers. Are you sure you want to do this?";
internal const string NonPlayerKillerMessage =
"Using this altar will make you a non-player killer, unable to attack or be attacked by other player killers. Are you sure you want to do this?";
internal const string VolatileRareMessage =
"Are you sure you want to use this rare item?";
private readonly RetailDialogFactory _dialogs;
private readonly ItemInteractionController _items;
private bool _disposed;
public RetailItemConfirmationController(
RetailDialogFactory dialogs,
ItemInteractionController items)
{
_dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
_items = items ?? throw new ArgumentNullException(nameof(items));
_items.PolicyActionRequested += OnPolicyActionRequested;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_items.PolicyActionRequested -= OnPolicyActionRequested;
}
private void OnPolicyActionRequested(ItemPolicyAction action)
{
string? message = action.Kind switch
{
ItemPolicyActionKind.ConfirmPlayerKillerSwitch => PlayerKillerMessage,
ItemPolicyActionKind.ConfirmNonPlayerKillerSwitch => NonPlayerKillerMessage,
ItemPolicyActionKind.ConfirmVolatileRare => VolatileRareMessage,
_ => null,
};
if (message is null)
return;
RetailDialogData data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.UsageObjectId, action.ObjectId);
_dialogs.MakeDialog(data, OnUsageDialogDone);
}
private void OnUsageDialogDone(RetailDialogData data)
{
if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult))
return;
uint objectId = data.GetUInt32(RetailDialogProperty.UsageObjectId);
_items.ExecuteConfirmedUse(objectId);
}
}

View file

@ -107,6 +107,9 @@ public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback, CursorFeedbackController Feedback,
RetailCursorManager Manager); RetailCursorManager Manager);
public sealed record ConfirmationRuntimeBindings(
Action<uint, uint, bool> SendResponse);
public sealed record RetailUiRuntimeBindings( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
RetailUiAssets Assets, RetailUiAssets Assets,
@ -120,6 +123,7 @@ public sealed record RetailUiRuntimeBindings(
CharacterRuntimeBindings Character, CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory, InventoryRuntimeBindings Inventory,
RetailUiCursorBindings Cursor, RetailUiCursorBindings Cursor,
ConfirmationRuntimeBindings Confirmations,
StackSplitQuantityState StackSplitQuantity, StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins, BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence, RetailUiPersistenceBindings? Persistence,
@ -136,6 +140,8 @@ public sealed class RetailUiRuntime : IDisposable
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity; private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence; private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation; private readonly RetailUiAutomationScriptRunner? _automation;
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private bool _disposed; private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings) private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@ -148,7 +154,7 @@ public sealed class RetailUiRuntime : IDisposable
MountToolbar(); MountToolbar();
MountCombat(); MountCombat();
MountJumpPowerbar(); MountJumpPowerbar();
MountConfirmationDialogs(); MountDialogFactory();
MountCharacter(); MountCharacter();
MountPlugins(); MountPlugins();
MountInventory(); MountInventory();
@ -191,7 +197,7 @@ public sealed class RetailUiRuntime : IDisposable
public SelectedObjectController? SelectedObjectController { get; private set; } public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; }
public RetailConfirmationDialogService? ConfirmationDialogs { get; private set; } public RetailDialogFactory? DialogFactory { get; private set; }
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{ {
@ -212,7 +218,7 @@ public sealed class RetailUiRuntime : IDisposable
FpsController?.Tick(); FpsController?.Tick();
JumpPowerbarController?.Tick(); JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
ConfirmationDialogs?.Tick(); DialogFactory?.Tick();
Host.Tick(deltaSeconds); Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds); _automation?.Tick(deltaSeconds);
} }
@ -222,6 +228,21 @@ public sealed class RetailUiRuntime : IDisposable
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action) public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> ToolbarInputController?.Handle(action) == true; => ToolbarInputController?.Handle(action) == true;
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
=> _gameplayConfirmationController?.HandleRequest(request) == true;
public bool HandleConfirmationDone(GameEvents.CharacterConfirmationDone done)
=> _gameplayConfirmationController?.HandleDone(done) == true;
public uint ShowConfirmation(string message, Action<bool> completed)
{
ArgumentNullException.ThrowIfNull(message);
ArgumentNullException.ThrowIfNull(completed);
return DialogFactory?.MakeConfirmation(
message,
data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u;
}
public void UpdateCursor(IEnumerable<IMouse> mice) public void UpdateCursor(IEnumerable<IMouse> mice)
{ {
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);
@ -645,34 +666,49 @@ public sealed class RetailUiRuntime : IDisposable
Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072."); Console.WriteLine("[D.6] retail jump bar from gmFloatyPowerBarUI LayoutDesc 0x21000072.");
} }
private void MountConfirmationDialogs() private void MountDialogFactory()
{ {
ImportedLayout? layout;
uint layoutId; uint layoutId;
lock (_bindings.Assets.DatLock) lock (_bindings.Assets.DatLock)
{ {
// DialogFactory::CreateDialog_ @ 0x00477AD0 calls // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// GetDIDByEnum(2, 5), then creates root element 0x15. // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u); layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
layout = layoutId == 0u }
? null
: LayoutImporter.Import( if (layoutId == 0u)
{
Console.WriteLine("[UI] retail dialog catalog could not be resolved.");
return;
}
ImportedLayout? CreateLayout(RetailDialogType type)
{
uint rootElementId = RetailDialogFactory.RootElementId(type);
if (rootElementId == 0u)
return null;
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats, _bindings.Assets.Dats,
layoutId, layoutId,
RetailConfirmationDialogService.RootElementId, rootElementId,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont); _bindings.Assets.ResolveFont);
} }
if (layout is null)
{
Console.WriteLine("[UI] confirmation dialog catalog could not be resolved.");
return;
} }
ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout); DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout);
Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15."); _gameplayConfirmationController = new GameplayConfirmationController(
DialogFactory,
_bindings.Confirmations.SendResponse);
_itemConfirmationController = new RetailItemConfirmationController(
DialogFactory,
ItemInteraction);
Console.WriteLine(
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
} }
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits() private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
@ -889,6 +925,9 @@ public sealed class RetailUiRuntime : IDisposable
_disposed = true; _disposed = true;
_persistence?.Dispose(); _persistence?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
_itemConfirmationController?.Dispose();
_gameplayConfirmationController?.Dispose();
DialogFactory?.Dispose();
Host.Dispose(); Host.Dispose();
} }
} }

View file

@ -73,6 +73,7 @@ public static class GameEventWiring
Action<uint /*weenieError*/>? onUseDone = null, Action<uint /*weenieError*/>? onUseDone = null,
ItemManaState? itemMana = null, ItemManaState? itemMana = null,
Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null, Action<GameEvents.CharacterConfirmationRequest>? onConfirmationRequest = null,
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
FriendsState? friends = null, FriendsState? friends = null,
SquelchState? squelch = null, SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null) Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null)
@ -123,6 +124,16 @@ public static class GameEventWiring
}); });
} }
if (onConfirmationDone is not null)
{
dispatcher.Register(GameEventType.CharacterConfirmationDone, e =>
{
var done = GameEvents.ParseCharacterConfirmationDone(e.Payload.Span);
if (done is not null)
onConfirmationDone(done.Value);
});
}
if (friends is not null) if (friends is not null)
{ {
dispatcher.Register(GameEventType.FriendsListUpdate, e => dispatcher.Register(GameEventType.FriendsListUpdate, e =>

View file

@ -505,6 +505,22 @@ public static class GameEvents
catch { return null; } catch { return null; }
} }
/// <summary>
/// 0x0276 CharacterConfirmationDone — server cancellation/completion of the
/// outstanding confirmation tuple. Retail dispatches the same type/context
/// pair to <c>RecvNotice_AbortConfirmationRequest</c>.
/// </summary>
public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId);
public static CharacterConfirmationDone? ParseCharacterConfirmationDone(
ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new CharacterConfirmationDone(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
// ── Shared string reader (matches LoginRequest.ReadString16L) ─────────── // ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos) private static string ReadString16L(ReadOnlySpan<byte> source, ref int pos)

View file

@ -0,0 +1,58 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Tests.UI.Layout;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.UI;
public sealed class GameplayConfirmationControllerTests
{
[Fact]
public void SkillRequestAppendsContinueAndCloseNoticeSendsServerTuple()
{
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
Assert.True(controller.HandleRequest(
new GameEvents.CharacterConfirmationRequest(2u, 42u, "Raise this skill?")));
Assert.Equal(
"Raise this skill? Continue?",
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([(2u, 42u, true)], responses);
Assert.Equal(0u, controller.ActiveDialogContext);
}
[Fact]
public void MatchingConfirmationDoneClosesDialogAndUnmatchedTupleDoesNothing()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var factory = new RetailDialogFactory(root, _ =>
FixtureLoader.LoadConfirmationDialog());
var responses = new List<(uint Type, uint Context, bool Accepted)>();
using var controller = new GameplayConfirmationController(
factory,
(type, context, accepted) => responses.Add((type, context, accepted)));
controller.HandleRequest(new GameEvents.CharacterConfirmationRequest(7u, 99u, "Proceed?"));
Assert.False(controller.HandleDone(
new GameEvents.CharacterConfirmationDone(7u, 100u)));
Assert.True(factory.IsOpen);
Assert.True(controller.HandleDone(
new GameEvents.CharacterConfirmationDone(7u, 99u)));
Assert.False(factory.IsOpen);
Assert.Equal([(7u, 99u, false)], responses);
}
}

View file

@ -1,67 +0,0 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailConfirmationDialogServiceTests
{
[Fact]
public void FixtureBuildsProductionConfirmationWidgets()
{
var layout = FixtureLoader.LoadConfirmationDialog();
Assert.IsType<UiDialogRoot>(layout.Root);
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogService.RejectButtonId));
}
[Fact]
public void ShowCentersModalAndAcceptCompletesIt()
{
var root = new UiRoot { Width = 1024f, Height = 768f };
var layout = FixtureLoader.LoadConfirmationDialog();
var service = new RetailConfirmationDialogService(root, layout);
bool? result = null;
service.Show("Do you really want to kill your character?", accepted => result = accepted);
Assert.Same(layout.Root, root.Modal);
var popup = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(RetailConfirmationDialogService.PopupElementId));
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
var accept = Assert.IsType<UiButton>(
layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
accept.OnClick!();
Assert.True(result);
Assert.Null(root.Modal);
Assert.False(service.IsOpen);
}
[Fact]
public void RequestsArePresentedInFifoOrder()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layout = FixtureLoader.LoadConfirmationDialog();
var service = new RetailConfirmationDialogService(root, layout);
var results = new List<string>();
service.Show("first", accepted => results.Add($"first:{accepted}"));
service.Show("second", accepted => results.Add($"second:{accepted}"));
Assert.Equal(1, service.PendingCount);
Assert.IsType<UiButton>(layout.FindElement(
RetailConfirmationDialogService.RejectButtonId)).OnClick!();
Assert.Equal(["first:False"], results);
Assert.True(service.IsOpen);
Assert.Equal(0, service.PendingCount);
Assert.IsType<UiButton>(layout.FindElement(
RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
Assert.Equal(["first:False", "second:True"], results);
Assert.False(service.IsOpen);
}
}

View file

@ -0,0 +1,213 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailDialogFactoryTests
{
[Theory]
[InlineData(RetailDialogType.Confirmation, 0x15u)]
[InlineData(RetailDialogType.Wait, 0x31u)]
[InlineData(RetailDialogType.Message, 0x24u)]
[InlineData(RetailDialogType.TextInput, 0x28u)]
[InlineData(RetailDialogType.ConfirmationTextInput, 0x2Cu)]
[InlineData(RetailDialogType.Menu, 0x1Bu)]
[InlineData(RetailDialogType.ConfirmationMenu, 0x1Fu)]
public void DialogTypesMapToRetailCatalogRoots(
RetailDialogType type,
uint expectedRoot)
=> Assert.Equal(expectedRoot, RetailDialogFactory.RootElementId(type));
[Fact]
public void FixtureBuildsProductionConfirmationWidgets()
{
var layout = FixtureLoader.LoadConfirmationDialog();
Assert.IsType<UiDialogRoot>(layout.Root);
Assert.IsType<UiText>(layout.FindElement(RetailConfirmationDialogView.MessageElementId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.AcceptButtonId));
Assert.IsType<UiButton>(layout.FindElement(RetailConfirmationDialogView.RejectButtonId));
}
[Fact]
public void ConfirmationCreatesFreshCenteredRootAndReturnsPropertyResult()
{
var root = new UiRoot { Width = 1024f, Height = 768f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
bool? result = null;
factory.MakeConfirmation(
"Do you really want to kill your character?",
data => result = data.GetBoolean(RetailDialogProperty.ConfirmationResult));
ImportedLayout layout = Assert.Single(layouts);
Assert.Same(layout.Root, root.Modal);
var popup = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(RetailConfirmationDialogView.PopupElementId));
Assert.Equal(MathF.Round((1024f - popup.Width) * 0.5f), popup.Left);
Assert.Equal(MathF.Round((768f - popup.Height) * 0.5f), popup.Top);
Accept(layout);
Assert.True(result);
Assert.Null(root.Modal);
Assert.False(factory.IsOpen);
}
[Fact]
public void SameQueuePresentsFifoUsingFreshLiveRoots()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
var results = new List<string>();
factory.MakeConfirmation("first", data =>
results.Add($"first:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
factory.MakeConfirmation("second", data =>
results.Add($"second:{data.GetBoolean(RetailDialogProperty.ConfirmationResult)}"));
Assert.Equal(1, factory.PendingCount);
Reject(layouts[0]);
Assert.Equal(["first:False"], results);
Assert.Equal(2, layouts.Count);
Assert.NotSame(layouts[0].Root, layouts[1].Root);
Assert.Same(layouts[1].Root, root.Modal);
Assert.Equal("second", Message(layouts[1]));
Accept(layouts[1]);
Assert.Equal(["first:False", "second:True"], results);
Assert.False(factory.IsOpen);
}
[Fact]
public void QueueGroupsAndNonQueuedDialogsCanBeActiveTogether()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
uint first = factory.MakeConfirmation("queue two", queueKey: 2u);
uint second = factory.MakeConfirmation("queue three", queueKey: 3u);
uint third = factory.MakeConfirmation("nonqueued", queueKey: 1u);
Assert.Equal(3, factory.ActiveCount);
Assert.Equal(0, factory.PendingCount);
Assert.Same(layouts[2].Root, root.Modal);
Assert.True(factory.CloseDialog(third));
Assert.Same(layouts[1].Root, root.Modal);
Assert.True(factory.CloseDialog(second));
Assert.Same(layouts[0].Root, root.Modal);
Assert.True(factory.CloseDialog(first));
Assert.Null(root.Modal);
}
[Fact]
public void PriorityDialogSuspendsCurrentAndRestoresItBeforeOlderPendingWork()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
factory.MakeConfirmation("current");
factory.MakeConfirmation("ordinary pending");
factory.MakeConfirmation("priority", priority: true);
Assert.Equal(2, factory.PendingCount);
Assert.Equal("priority", Message(layouts[1]));
Assert.DoesNotContain(layouts[0].Root, root.Children);
Reject(layouts[1]);
Assert.Equal(3, layouts.Count);
Assert.Equal("current", Message(layouts[2]));
Assert.Equal(1, factory.PendingCount);
Assert.NotSame(layouts[0].Root, layouts[2].Root);
}
[Fact]
public void CallbackPrecedesCloseNoticeAndCustomLabelsAreApplied()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
var order = new List<string>();
factory.DialogClosed += (_, _) => order.Add("notice");
RetailDialogData data = RetailDialogData.Confirmation("Proceed?")
.Set(RetailDialogProperty.AcceptLabel, "Yes")
.Set(RetailDialogProperty.RejectLabel, "No");
factory.MakeDialog(data, _ => order.Add("callback"));
Assert.Equal("Yes", Button(layouts[0], RetailConfirmationDialogView.AcceptButtonId).Label);
Assert.Equal("No", Button(layouts[0], RetailConfirmationDialogView.RejectButtonId).Label);
Accept(layouts[0]);
Assert.Equal(["callback", "notice"], order);
}
[Fact]
public void PendingContextCanBeClosedWithoutDisturbingActiveDialog()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
var completed = new List<uint>();
uint active = factory.MakeConfirmation("active");
uint pending = 0u;
pending = factory.MakeConfirmation("pending", _ => completed.Add(pending));
Assert.True(factory.CloseDialog(pending));
Assert.Equal([pending], completed);
Assert.Equal(0, factory.PendingCount);
Assert.Same(layouts[0].Root, root.Modal);
Assert.True(factory.CloseDialog(active));
}
[Fact]
public void ResetCompletesActiveAndPendingContexts()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = CreateFactory(root, layouts);
var completed = new List<string>();
var notices = new List<uint>();
factory.DialogClosed += (context, _) => notices.Add(context);
uint active = factory.MakeConfirmation("active", _ => completed.Add("active"));
uint pending = factory.MakeConfirmation("pending", _ => completed.Add("pending"));
factory.Reset();
Assert.Equal(["active", "pending"], completed);
Assert.Equal([active, pending], notices);
Assert.False(factory.IsOpen);
Assert.Equal(0, factory.PendingCount);
Assert.Null(root.Modal);
}
private static RetailDialogFactory CreateFactory(
UiRoot root,
List<ImportedLayout> layouts)
=> new(root, type =>
{
Assert.Equal(RetailDialogType.Confirmation, type);
ImportedLayout layout = FixtureLoader.LoadConfirmationDialog();
layouts.Add(layout);
return layout;
});
private static UiButton Button(ImportedLayout layout, uint id)
=> Assert.IsType<UiButton>(layout.FindElement(id));
private static void Accept(ImportedLayout layout)
=> Button(layout, RetailConfirmationDialogView.AcceptButtonId).OnClick!();
private static void Reject(ImportedLayout layout)
=> Button(layout, RetailConfirmationDialogView.RejectButtonId).OnClick!();
private static string Message(ImportedLayout layout)
=> string.Join(" ", Assert.IsType<UiText>(layout.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
}

View file

@ -53,7 +53,7 @@ public sealed class RetailLayoutFixtureGenerator
var dialogs = LayoutImporter.ImportInfos( var dialogs = LayoutImporter.ImportInfos(
dats, dats,
dialogsLayoutDid, dialogsLayoutDid,
RetailConfirmationDialogService.RootElementId); RetailDialogFactory.RootElementId(RetailDialogType.Confirmation));
Assert.NotNull(dialogs); Assert.NotNull(dialogs);
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
{ {

View file

@ -0,0 +1,102 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Tests.UI.Layout;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class RetailItemConfirmationControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000002u;
private const uint Rare = 0x50000003u;
[Fact]
public void VolatileRareSendsUseOnlyAfterPositiveFactoryCallback()
{
var objects = BuildObjects(PublicWeenieFlags.VolatileRare);
var uses = new List<uint>();
var items = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1000L);
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
using var confirmations = new RetailItemConfirmationController(factory, items);
Assert.True(items.ActivateItem(Rare));
Assert.Empty(uses);
Assert.Equal(0, items.BusyCount);
Assert.Equal(
RetailItemConfirmationController.VolatileRareMessage,
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
Assert.IsType<UiButton>(shown.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.Equal([Rare], uses);
Assert.Equal(1, items.BusyCount);
}
[Fact]
public void RejectedPlayerKillerAltarDoesNotSendUse()
{
var objects = BuildObjects(PublicWeenieFlags.PlayerKillerSwitch);
var uses = new List<uint>();
var items = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
nowMs: () => 1000L);
var root = new UiRoot { Width = 800f, Height = 600f };
ImportedLayout? shown = null;
var factory = new RetailDialogFactory(root, _ =>
shown = FixtureLoader.LoadConfirmationDialog());
using var confirmations = new RetailItemConfirmationController(factory, items);
Assert.True(items.ActivateItem(Rare));
Assert.IsType<UiButton>(shown!.FindElement(
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
Assert.Empty(uses);
Assert.Equal(0, items.BusyCount);
}
private static ClientObjectTable BuildObjects(PublicWeenieFlags flags)
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
Type = ItemType.Creature,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = Pack,
Name = "Backpack",
Type = ItemType.Container,
});
objects.MoveItem(Pack, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = Rare,
Name = "Rare",
Type = ItemType.Misc,
Useability = ItemUseability.Contained,
PublicWeenieBitfield = (uint)flags,
});
objects.MoveItem(Rare, Pack, 0);
return objects;
}
}

View file

@ -174,6 +174,30 @@ public sealed class GameEventWiringTests
Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received); Assert.Equal(new GameEvents.CharacterConfirmationRequest(7u, 42u, "Proceed?"), received);
} }
[Fact]
public void WireAll_ConfirmationDone_UsesRetailTypeAndContextTuple()
{
var dispatcher = new GameEventDispatcher();
GameEvents.CharacterConfirmationDone? received = null;
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
new Spellbook(),
new ChatLog(),
onConfirmationDone: done => received = done);
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 6u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 314u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(
GameEventType.CharacterConfirmationDone,
payload));
dispatcher.Dispatch(env!.Value);
Assert.Equal(new GameEvents.CharacterConfirmationDone(6u, 314u), received);
}
[Fact] [Fact]
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState() public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
{ {