diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index e03ea0ad..fa5e05d2 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -70,7 +70,8 @@ fill-components. `ClientCommandController` keeps the panel layer independent
of App/network services; `WorldSession` sends the exact game-action or control
message for server-backed families. Friends and squelch databases are parsed
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
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
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:**
`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-dialog-factory-pseudocode.md`;
`ClientCommunicationSystem::OnChatCommand @ 0x00581320`;
`ClientCommunicationSystem::DoLifestone @ 0x0056FC70`;
`CM_Character::Event_TeleToLifestone @ 0x006A1B90`.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3b01a7ff..2ef898d4 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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-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-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-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` |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 7f272db1..0c352709 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -501,7 +501,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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 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.
-- **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 — 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.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index afa1e888..7134e811 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -495,6 +495,10 @@ include dungeons.
The first connected gate passed the family except for raw suicide success
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.
+ 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).
**Freeze on landing:**
diff --git a/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md b/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md
new file mode 100644
index 00000000..7bb37189
--- /dev/null
+++ b/docs/research/2026-07-13-retail-dialog-factory-pseudocode.md
@@ -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
+```
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 0f16d7bc..27424c48 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2208,6 +2208,9 @@ public sealed class GameWindow : IDisposable
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
+ Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
+ (type, context, accepted) =>
+ _liveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
@@ -2659,12 +2662,9 @@ public sealed class GameWindow : IDisposable
onUseDone: error => _itemInteractionController?.CompleteUse(error),
itemMana: ItemMana,
onConfirmationRequest: request =>
- _retailUiRuntime?.ConfirmationDialogs?.Show(
- request.Message,
- accepted => session.SendConfirmationResponse(
- request.Type,
- request.ContextId,
- accepted)),
+ _retailUiRuntime?.HandleConfirmationRequest(request),
+ onConfirmationDone: done =>
+ _retailUiRuntime?.HandleConfirmationDone(done),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components);
@@ -2764,7 +2764,7 @@ public sealed class GameWindow : IDisposable
LastOutsideCorpsePosition: () =>
LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
- _retailUiRuntime?.ConfirmationDialogs?.Show(message, completed),
+ _retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: liveSession.SendSuicide,
ClearChat: _ => chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs
new file mode 100644
index 00000000..c8b81136
--- /dev/null
+++ b/src/AcDream.App/UI/GameplayConfirmationController.cs
@@ -0,0 +1,91 @@
+using AcDream.App.UI.Layout;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.App.UI;
+
+///
+/// Ports the server-driven confirmation ownership in
+/// gmGamePlayUI @ 0x004E9D70..0x004E9EB6. The dialog factory broadcasts
+/// completion; this semantic owner retains the server type/context and sends the
+/// matching confirmation response.
+///
+public sealed class GameplayConfirmationController : IDisposable
+{
+ private readonly RetailDialogFactory _dialogs;
+ private readonly Action _sendResponse;
+ private uint _dialogContext;
+ private uint _serverType;
+ private uint _serverContext;
+ private bool _disposed;
+
+ public GameplayConfirmationController(
+ RetailDialogFactory dialogs,
+ Action 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;
+ }
+}
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index a52eac19..644fff9d 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -288,6 +288,21 @@ public sealed class ItemInteractionController : IDisposable
public bool AcquireSelfTarget()
=> IsTargetModeActive && AcquireTarget(_playerGuid());
+ ///
+ /// Completes the positive branch of retail
+ /// ClientUISystem::UsageCallback @ 0x00565B20. 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.
+ ///
+ public bool ExecuteConfirmedUse(uint objectId)
+ {
+ if (objectId == 0u || _sendUse is null)
+ return false;
+ _sendUse(objectId);
+ _busyCount++;
+ return true;
+ }
+
public void CancelTargetMode()
{
if (!IsAnyTargetModeActive) return;
diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
deleted file mode 100644
index 86d42c46..00000000
--- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogService.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-namespace AcDream.App.UI.Layout;
-
-///
-/// Retained implementation of retail DialogFactory's confirmation queue.
-/// The visual tree is element 0x15 from the shared dialog catalog resolved by
-/// GetDIDByEnum(2, 5); callbacks mirror property 0x92 from
-/// ConfirmationDialog::ListenToElementMessage @ 0x00476670.
-///
-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 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 _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 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 measure = _message.DatFont is { } font
- ? font.MeasureWidth
- : static text => text.Length * 8f;
- IReadOnlyList 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();
- }
-}
diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs
new file mode 100644
index 00000000..4707dbb9
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs
@@ -0,0 +1,139 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// One live type-1 retail confirmation root. Instances are intentionally short-lived:
+/// DialogFactory::CreateDialog_ @ 0x00477AD0 creates a fresh catalog root for
+/// every displayed DialogInfo.
+///
+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 _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 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 measure = _message.DatFont is { } font
+ ? font.MeasureWidth
+ : static value => value.Length * 8f;
+ IReadOnlyList 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);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs
new file mode 100644
index 00000000..482b9a8c
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs
@@ -0,0 +1,113 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Property identifiers consumed by retail's Dialog and
+/// DialogFactory implementations.
+///
+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;
+ ///
+ /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean
+ /// attribute 0x40. The Keystone-owned attribute name is unavailable.
+ ///
+ public const uint ElementAttribute40 = 0xACu;
+ public const uint QueueKey = 0xC3u;
+ public const uint Message = 0xC5u;
+ public const uint UsageObjectId = 0x1000003Du;
+}
+
+///
+/// Retail dialog types selected by property 0x8E in
+/// DialogFactory::CreateDialog_ @ 0x00477AD0.
+///
+public enum RetailDialogType : uint
+{
+ Confirmation = 1,
+ Wait = 2,
+ Message = 3,
+ TextInput = 4,
+ ConfirmationTextInput = 5,
+ Menu = 6,
+ ConfirmationMenu = 7,
+}
+
+///
+/// Modern typed carrier for retail's PropertyCollection dialog contract.
+/// The raw numeric keys remain visible because type-specific presenters and semantic
+/// callbacks both extend the same collection in retail.
+///
+public sealed class RetailDialogData
+{
+ private readonly Dictionary _values = new();
+
+ public IReadOnlyDictionary Values => _values;
+
+ public RetailDialogData Set(uint propertyId, T value) where T : notnull
+ {
+ _values[propertyId] = value;
+ return this;
+ }
+
+ public bool Contains(uint propertyId) => _values.ContainsKey(propertyId);
+
+ public bool TryGet(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);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
new file mode 100644
index 00000000..2311e278
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs
@@ -0,0 +1,323 @@
+namespace AcDream.App.UI.Layout;
+
+///
+/// Retained-mode port of retail DialogFactory @ 0x004773C0..0x00478470.
+/// It owns dialog contexts, independent FIFO queue groups, nonqueued dialogs,
+/// priority preemption, callback delivery, close notices, and fresh catalog roots.
+///
+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? Callback { get; init; }
+ public RetailConfirmationDialogView? View { get; set; }
+ }
+
+ private readonly UiRoot _host;
+ private readonly Func _createLayout;
+ private readonly Dictionary _activeQueued = new();
+ private readonly Dictionary _activeNonQueued = new();
+ private readonly Dictionary> _pending = new();
+ private readonly List _openOrder = new();
+ private uint _globalContext;
+ private bool _disposed;
+
+ public RetailDialogFactory(
+ UiRoot host,
+ Func createLayout)
+ {
+ _host = host ?? throw new ArgumentNullException(nameof(host));
+ _createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
+ }
+
+ ///
+ /// Retail's global close-dialog notice, raised after the per-context callback and
+ /// before the live root is removed from its parent.
+ ///
+ public event Action? DialogClosed;
+
+ public event Action? 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);
+
+ /// Exact root-element switch from CreateDialog_ @ 0x00477AD0.
+ 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);
+
+ ///
+ /// Retail MakeCallbackDialogInCurrentUI @ 0x00478430.
+ ///
+ public uint MakeDialog(RetailDialogData data, Action? 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 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? 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);
+ }
+
+ ///
+ /// Retail CloseDialog @ 0x00478160. The context can identify an active
+ /// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
+ ///
+ 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 queue) in _pending.ToArray())
+ {
+ LinkedListNode? 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();
+ }
+
+ ///
+ /// Retail DialogFactory::Reset @ 0x00477950 completes active and pending
+ /// infos before clearing the factory.
+ ///
+ 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 PendingQueue(uint queueKey)
+ {
+ if (_pending.TryGetValue(queueKey, out LinkedList? queue))
+ return queue;
+ queue = new LinkedList();
+ _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? 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? queue)
+ ? queue.Count
+ : 0;
+ active.View?.SetPendingCount(count);
+ }
+ }
+
+ private void RefreshModal()
+ {
+ _host.Modal = _openOrder.Count == 0 ? null : _openOrder[^1].View?.Root;
+ }
+}
diff --git a/src/AcDream.App/UI/RetailItemConfirmationController.cs b/src/AcDream.App/UI/RetailItemConfirmationController.cs
new file mode 100644
index 00000000..e16c8134
--- /dev/null
+++ b/src/AcDream.App/UI/RetailItemConfirmationController.cs
@@ -0,0 +1,65 @@
+using AcDream.App.UI.Layout;
+using AcDream.Core.Items;
+
+namespace AcDream.App.UI;
+
+///
+/// Retained UI owner for the item-use confirmations emitted by
+/// . Message/data construction mirrors
+/// ClientUISystem::UsageConfirmation_* @ 0x00566420..0x005669EA and
+/// completion mirrors ClientUISystem::UsageCallback @ 0x00565B20.
+///
+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);
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 10068258..49967cc3 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -107,6 +107,9 @@ public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback,
RetailCursorManager Manager);
+public sealed record ConfirmationRuntimeBindings(
+ Action SendResponse);
+
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
@@ -120,6 +123,7 @@ public sealed record RetailUiRuntimeBindings(
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
RetailUiCursorBindings Cursor,
+ ConfirmationRuntimeBindings Confirmations,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
@@ -136,6 +140,8 @@ public sealed class RetailUiRuntime : IDisposable
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
+ private GameplayConfirmationController? _gameplayConfirmationController;
+ private RetailItemConfirmationController? _itemConfirmationController;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@@ -148,7 +154,7 @@ public sealed class RetailUiRuntime : IDisposable
MountToolbar();
MountCombat();
MountJumpPowerbar();
- MountConfirmationDialogs();
+ MountDialogFactory();
MountCharacter();
MountPlugins();
MountInventory();
@@ -191,7 +197,7 @@ public sealed class RetailUiRuntime : IDisposable
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { 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)
{
@@ -212,7 +218,7 @@ public sealed class RetailUiRuntime : IDisposable
FpsController?.Tick();
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
- ConfirmationDialogs?.Tick();
+ DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
}
@@ -222,6 +228,21 @@ public sealed class RetailUiRuntime : IDisposable
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
=> 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 completed)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ ArgumentNullException.ThrowIfNull(completed);
+ return DialogFactory?.MakeConfirmation(
+ message,
+ data => completed(data.GetBoolean(RetailDialogProperty.ConfirmationResult))) ?? 0u;
+ }
+
public void UpdateCursor(IEnumerable mice)
{
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.");
}
- private void MountConfirmationDialogs()
+ private void MountDialogFactory()
{
- ImportedLayout? layout;
uint layoutId;
lock (_bindings.Assets.DatLock)
{
- // DialogFactory::CreateDialog_ @ 0x00477AD0 calls
- // GetDIDByEnum(2, 5), then creates root element 0x15.
+ // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
+ // 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);
- layout = layoutId == 0u
- ? null
- : LayoutImporter.Import(
- _bindings.Assets.Dats,
- layoutId,
- RetailConfirmationDialogService.RootElementId,
- _bindings.Assets.ResolveSprite,
- _bindings.Assets.DefaultFont,
- _bindings.Assets.ResolveFont);
}
- if (layout is null)
+ if (layoutId == 0u)
{
- Console.WriteLine("[UI] confirmation dialog catalog could not be resolved.");
+ Console.WriteLine("[UI] retail dialog catalog could not be resolved.");
return;
}
- ConfirmationDialogs = new RetailConfirmationDialogService(Host.Root, layout);
- Console.WriteLine($"[UI] retail confirmation dialogs from LayoutDesc 0x{layoutId:X8} element 0x15.");
+ ImportedLayout? CreateLayout(RetailDialogType type)
+ {
+ uint rootElementId = RetailDialogFactory.RootElementId(type);
+ if (rootElementId == 0u)
+ return null;
+ lock (_bindings.Assets.DatLock)
+ {
+ return LayoutImporter.Import(
+ _bindings.Assets.Dats,
+ layoutId,
+ rootElementId,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont);
+ }
+ }
+
+ DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout);
+ _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()
@@ -889,6 +925,9 @@ public sealed class RetailUiRuntime : IDisposable
_disposed = true;
_persistence?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
+ _itemConfirmationController?.Dispose();
+ _gameplayConfirmationController?.Dispose();
+ DialogFactory?.Dispose();
Host.Dispose();
}
}
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index b5dc3a70..a37a7c51 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -73,6 +73,7 @@ public static class GameEventWiring
Action? onUseDone = null,
ItemManaState? itemMana = null,
Action? onConfirmationRequest = null,
+ Action? onConfirmationDone = null,
FriendsState? friends = null,
SquelchState? squelch = null,
Action>? 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)
{
dispatcher.Register(GameEventType.FriendsListUpdate, e =>
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index c4c00d86..e92890c4 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -505,6 +505,22 @@ public static class GameEvents
catch { return null; }
}
+ ///
+ /// 0x0276 CharacterConfirmationDone — server cancellation/completion of the
+ /// outstanding confirmation tuple. Retail dispatches the same type/context
+ /// pair to RecvNotice_AbortConfirmationRequest.
+ ///
+ public readonly record struct CharacterConfirmationDone(uint Type, uint ContextId);
+
+ public static CharacterConfirmationDone? ParseCharacterConfirmationDone(
+ ReadOnlySpan payload)
+ {
+ if (payload.Length < 8) return null;
+ return new CharacterConfirmationDone(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
+ }
+
// ── Shared string reader (matches LoginRequest.ReadString16L) ───────────
private static string ReadString16L(ReadOnlySpan source, ref int pos)
diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
new file mode 100644
index 00000000..98da961c
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs
@@ -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(shown!.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
+
+ Assert.IsType(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);
+ }
+
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs
deleted file mode 100644
index a46a3bc9..00000000
--- a/tests/AcDream.App.Tests/UI/Layout/RetailConfirmationDialogServiceTests.cs
+++ /dev/null
@@ -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(layout.Root);
- Assert.IsType(layout.FindElement(RetailConfirmationDialogService.MessageElementId));
- Assert.IsType(layout.FindElement(RetailConfirmationDialogService.AcceptButtonId));
- Assert.IsType(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(
- 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(
- 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();
-
- service.Show("first", accepted => results.Add($"first:{accepted}"));
- service.Show("second", accepted => results.Add($"second:{accepted}"));
-
- Assert.Equal(1, service.PendingCount);
- Assert.IsType(layout.FindElement(
- RetailConfirmationDialogService.RejectButtonId)).OnClick!();
- Assert.Equal(["first:False"], results);
- Assert.True(service.IsOpen);
- Assert.Equal(0, service.PendingCount);
-
- Assert.IsType(layout.FindElement(
- RetailConfirmationDialogService.AcceptButtonId)).OnClick!();
- Assert.Equal(["first:False", "second:True"], results);
- Assert.False(service.IsOpen);
- }
-}
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
new file mode 100644
index 00000000..fecb6e43
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs
@@ -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(layout.Root);
+ Assert.IsType(layout.FindElement(RetailConfirmationDialogView.MessageElementId));
+ Assert.IsType(layout.FindElement(RetailConfirmationDialogView.AcceptButtonId));
+ Assert.IsType(layout.FindElement(RetailConfirmationDialogView.RejectButtonId));
+ }
+
+ [Fact]
+ public void ConfirmationCreatesFreshCenteredRootAndReturnsPropertyResult()
+ {
+ var root = new UiRoot { Width = 1024f, Height = 768f };
+ var layouts = new List();
+ 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(
+ 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();
+ var factory = CreateFactory(root, layouts);
+ var results = new List();
+
+ 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();
+ 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();
+ 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();
+ var factory = CreateFactory(root, layouts);
+ var order = new List();
+ 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();
+ var factory = CreateFactory(root, layouts);
+ var completed = new List();
+
+ 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();
+ var factory = CreateFactory(root, layouts);
+ var completed = new List();
+ var notices = new List();
+ 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 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(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(layout.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text));
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
index 4316864b..2c959a75 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailLayoutFixtureGenerator.cs
@@ -53,7 +53,7 @@ public sealed class RetailLayoutFixtureGenerator
var dialogs = LayoutImporter.ImportInfos(
dats,
dialogsLayoutDid,
- RetailConfirmationDialogService.RootElementId);
+ RetailDialogFactory.RootElementId(RetailDialogType.Confirmation));
Assert.NotNull(dialogs);
var dialogsJson = JsonSerializer.Serialize(dialogs, new JsonSerializerOptions
{
diff --git a/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs
new file mode 100644
index 00000000..3d5b179a
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/RetailItemConfirmationControllerTests.cs
@@ -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();
+ 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(shown!.FindElement(
+ RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
+
+ Assert.IsType(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();
+ 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(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;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
index 785ab039..95feaf85 100644
--- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
@@ -174,6 +174,30 @@ public sealed class GameEventWiringTests
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]
public void WireAll_FriendsUpdate_ReplacesAuthoritativeSocialState()
{