# Confirmation-dialog text & refused-drop notification — research (2026-08-13 user gate) **Scope:** two presentation gaps observed at the 2026-08-13 connected gate. Read-only research; no source edits. Both questions end in an implementation recipe. All retail addresses are the Sept 2013 EoR build (`docs/research/named-retail/acclient_2013_pseudo_c.txt`, cited below as `pseudo-c:` `@
`); register-elided constants were byte-decoded from the PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (the `reference_pe_byte_decode` method); dat template strings were dumped from the **installed** `client_local_English.dat` (`%USERPROFILE%\Documents\Asheron's Call\`, the same file the client runs against — relevant per open issue #383 installed-vs-fixture drift). --- ## Q1 — Fellowship-invite / allegiance-swear confirmation dialog text ### 1.1 What ACE actually sends in `0x0274 Character.ConfirmationRequest` Wire layout (`references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventConfirmationRequest.cs:7-13`): ``` uint32 confirmationType // 1 = SwearAllegiance, 4 = Fellowship (ConfirmationType.cs:5-12) uint32 contextId // per-player UIntSequence (ConfirmationManager.cs:35) String16L text ``` **The `text` field is the BARE PLAYER NAME in both campaign-FA cases — never a composed sentence:** | Type | Sender | `text` value | |---|---|---| | 4 Fellowship | `references/ACE/Source/ACE.Server/Entity/Fellowship.cs:126` — `EnqueueSend(new Confirmation_Fellowship(inviter.Guid, newMember.Guid), inviter.Name)` | the **inviter's** name | | 1 SwearAllegiance | `references/ACE/Source/ACE.Server/WorldObjects/Player_Allegiance.cs:90` — `EnqueueSend(new Confirmation_SwearAllegiance(patron.Guid, Guid), Name)` | the **would-be vassal's** name | Both route through `ConfirmationManager.EnqueueSend` (`references/ACE/Source/ACE.Server/WorldObjects/Managers/ConfirmationManager.cs:33-51`), which assigns the context id and arms a 30 s timeout. ACE's own comment in `GameEventConfirmationRequest.cs:8` ("172 is the max seen in retail pcaps") plus the retail client code below confirm retail servers sent the same shape — a short string the CLIENT wraps. ### 1.2 What retail composes per type (byte-verified end to end) Client dispatch: `ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0` (`pseudo-c:368625-368669`) is a bare 7-way switch on the type: - case **1** → `CM_Allegiance::SendNotice_SwearAllegianceRequest(text, ctx)` (`@0x006A7420`) - case **4** → `CM_Fellowship::SendNotice_FellowshipRequest(text, ctx)` (`@0x006A6650`) - cases 2/3/5/6/7 → the gmGamePlayUI family acdream already ports. Receivers and dialog builders: - `gmFellowshipUI::RecvNotice_FellowshipRequest @0x00490880` (`pseudo-c:156924`) → `gmFellowshipUI::MakeFellowRequestDialog @0x00490620` (`pseudo-c:156764`) - `gmAllegianceUI::RecvNotice_SwearAllegianceRequest @0x00493110` (`pseudo-c:159256`) → `gmAllegianceUI::MakeAcceptSwearConfirmationDialog @0x00492990` (`pseudo-c:158824`) Both builders are structurally identical (guarded by their own one-outstanding context field — `m_fellowRequestContext` / `m_acceptSwearContext` — and both store the server context id for the eventual `CM_Character::Event_ConfirmationResponse` `0x0275`): ``` StringInfo si; si.SetStringIDandTableEnum(, 0x10000001); // table enum → StringTable DID si.AddVariable_String(, wireText); // wireText = the bare name from 0x0274 PropertyCollection props; props[0x8E] = 1; // dialog kind props[0xC5] = si; // dialog text StringInfo DialogFactory::MakeDialogInCurrentUI(props); m_ui<...>ServerContextID = contextId; ``` The decompiler prints the `` and `` arguments as literal `0` — a known BN artifact (the globals' static initializer is 0; they are hashed at startup). **Byte-decoded from the paired binary:** - `MakeFellowRequestDialog` @0x0049065B: `A1 50 E1 83 00` = `mov eax, [0x0083E150]` → **`ID_Fellowship_FellowshipRequest`** (`pseudo-c:1147714`); @0x00490685: `8B 0D 70 E1 83 00` = `mov ecx, [0x0083E170]` → **`ID_Player`** (`pseudo-c:1147722`). - `MakeAcceptSwearConfirmationDialog` @0x004929CB: `A1 04 E2 83 00` → `[0x0083E204]` = **`ID_Allegiance_AcceptSwearConfirmation`** (`pseudo-c:1147783`); @0x004929F5: `8B 0D 34 E2 83 00` → `[0x0083E234]` = **`ID_Player`** (`pseudo-c:1147795`). The startup hash inits: `ID_Player = compute_str_hash("PLAYER")` (`pseudo-c:767722 @0x006EE4DD`) — and `compute_str_hash("PLAYER")` = **`0x05506DA2`**, which is EXACTLY the variable-name hash stored in the dat entries below, closing the chain. Table enum `0x10000001` maps (via `DBObj::GetDIDByEnum`, `StringInfo::SetStringIDandTableEnum @0x0042C760`, `pseudo-c:48643-48656`, DivineType `0x25` = StringTable) to StringTable DID **`0x23000001`** — empirically confirmed because the dump below found every key there, and `SocialAllegiancePageController` already resolves its sibling keys against `0x23000001` (`SocialAllegiancePageController.cs:139`). **The substitution mechanism** (this is the part AD-81/AD-85 recorded as unported): a StringTable entry is NOT "one string + variants" — for templated entries it is **N+1 literal fragments interleaved with N variables**. `StringInfo::InqStringInternal @0x0042E020` (`pseudo-c:50432`) builds a map {variableNameHash → resolved value} from the StringInfo's `m_variables`, then `StringTable::GetString @0x004300D0` (`pseudo-c:52601`) walks the entry — the no-metalanguage branch @0x004303B7 is the canonical shape: ``` out = ""; for i in 0 .. numStrings-1: out += strings[i]; // literal fragment if i < numVariables: out += varMap[variableNameHash[i]]; // substituted value ("" + fail flag if missing) ``` (The live path passes `useMetaLanguage=1` and goes through `StringTableMetaLanguage::RenderString` @0x004302B1 + `StripMetaLetters`, which additionally handles `%(...)`-style meta tokens — **none of the five strings below contain any**, so plain interleave is byte-equivalent for them. Decoding `RenderString` in general remains out of scope, exactly as AD-81 warns.) **The actual templates** (installed `client_local_English.dat`, StringTable `0x23000001`; hash = `DatStringResolver.ComputeHash` of the key, the exact `compute_str_hash @0x00413110` port): | Key | Hash | Fragments | Variables | |---|---|---|---| | `ID_Fellowship_FellowshipRequest` | `0x08D09E44` | `[""]` + `[" has invited you to join their fellowship. Do you accept?"]` | `[PLAYER=0x05506DA2]` | | `ID_Allegiance_AcceptSwearConfirmation` | `0x056EA6EE` | `[""]` + `[" would like to swear allegiance to you. Do you accept?"]` | `[PLAYER]` | | `ID_Allegiance_SwearConfirmation` (local, vassal-side) | `0x048B3F2E` | `["Do you wish to swear to ", "?"]` | `[PLAYER]` | | `ID_Allegiance_BreakConfirmation` (local) | `0x0BDA6FDE` | `["Are you sure you wish to break from ", "?"]` | `[PLAYER]` | | `ID_Allegiance_KickConfirmation` (local) | `0x09692CBE` | `["Are you sure you wish to kick ", " from your allegiance?"]` | `[PLAYER]` | So retail's composed dialogs are, exactly: - Fellowship invite (recruit sees): **` has invited you to join their fellowship. Do you accept?`** - Incoming swear (patron sees): **` would like to swear allegiance to you. Do you accept?`** - Local swear (vassal clicks Swear): **`Do you wish to swear to ?`** (name from `GetObjectName(NAME_APPROPRIATE)` — lane C `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 item 2) - Local break / kick: analogous. ### 1.3 What acdream does today, and exactly what is missing **Parse — present and correct.** `GameEvents.ParseCharacterConfirmationRequest` (`src/AcDream.Core.Net/Messages/GameEvents.cs:514-531`) reads type/contextId/String16L. Registered at `GameEventWiring.cs:319-326`, routed `LiveSessionRuntimeFactory.cs:315-316` → `RetailUiRuntime.HandleConfirmationRequest` (`RetailUiRuntime.cs:749-750`) → `GameplayConfirmationController.HandleRequest` (`src/AcDream.App/UI/GameplayConfirmationController.cs:32-56`). The response leg (`0x0275`) and Done leg (`0x0276`) are complete. **Display — the server-driven dialogs show the bare name.** `GameplayConfirmationController.HandleRequest:49-51` appends `" Continue?"` for types 2/3/5/6 (correct — that's retail's gmGamePlayUI handlers) and shows `request.Message` VERBATIM for everything else — so for types 1 and 4 the dialog body is just `+Horan` (ACE's bare name). **Missing: the client-side template resolve + PLAYER substitution of §1.2.** This is the gap register rows AD-81/AD-85 recorded (AD-85 item 3 explicitly: "ACE sends the target's bare `Name` as the ENTIRE confirmation message … retail's own client wraps it via the identical `StringInfo` mechanism"). **Display — the LOCAL swear/break/kick dialogs show a dangling fragment.** `SocialAllegiancePageController` resolves the templates at bind time (`SocialAllegiancePageController.cs:364-369`) through `DatStringResolver.Resolve` (`src/AcDream.App/UI/Layout/DatStringResolver.cs:27-45`) — but `Resolve` returns **`entry.Strings[token]`, i.e. fragment 0 only** (its "token selects one localized variant" model predates the fragment-interleave finding above). `OnSwearClick` (`SocialAllegiancePageController.cs:448-458`) then shows `_swearConfirmationTemplate ?? name` → the user sees literally **`Do you wish to swear to `** — truncated, no name, no `?`. That is the observed "missing text and no player name" for the outgoing swear confirm. Break/Kick (`:463-473`, `:480-490`) have the same shape. **Adjacent, recorded, NOT part of the minimal fix:** retail keeps types 1/4 in their gm-UI owners with SEPARATE one-outstanding guards; acdream's single generic dialog context (already documented in the controller's class comment, `GameplayConfirmationController.cs:36-40`) can refuse a type-4 while an unrelated gameplay confirm is open. Leave as-is. ### 1.4 Implementation recipe (Q1) Minimal, faithful, and it fixes all five dialogs with ONE primitive: 1. **Add a template-substituting resolve to `DatStringResolver`** (it already caches `StringTable`s and owns `ComputeHash`): ```csharp // StringTable::GetString @0x004300D0, no-metalanguage branch @0x004303B7: // fragments interleaved with variables; N vars, N or N+1 fragments. public string? ResolveTemplate(uint tableId, string key, IReadOnlyDictionary variables) { // look up entry by ComputeHash(key); return null if absent; // sb: for i in 0..Strings.Count-1 { sb.Append(Strings[i].Value); // if (i < Variables.Count) // sb.Append(variables.TryGetValue(Variables[i], out var v) ? v : ""); } } public static readonly uint PlayerVariable = ComputeHash("PLAYER"); // 0x05506DA2 ``` Guard: refuse (return null) if the entry contains metalanguage tokens is NOT needed for these five (verified token-free), but do not advertise this as a general `StringTableMetaLanguage` port — AD-81's scope note stands. 2. **Server-driven dialogs (types 1/4):** where `RetailUiRuntime.HandleConfirmationRequest` (or `GameplayConfirmationController.HandleRequest` via an injected `Func` — the controller is constructed at `RetailUiRuntime.cs:2981-2983` where `_bindings.Assets.Dats` is in scope, same as the 20 existing `new DatStringResolver(...)` sites) — resolve: - type 4 → `ResolveTemplate(0x23000001, "ID_Fellowship_FellowshipRequest", {PLAYER: request.Message})` - type 1 → `ResolveTemplate(0x23000001, "ID_Allegiance_AcceptSwearConfirmation", {PLAYER: request.Message})` - null → fall back to the current bare `request.Message` (never invent English — AD-85's disposition). Types 2/3/5/6/7 unchanged. 3. **Local swear/break/kick:** replace the bind-time `ResolveString(...)`-fragment-0 latch with the same `ResolveTemplate(0x23000001, key, {PLAYER: targetName})` at click time (the name is already fetched: `SocialAllegiancePageController.cs:450`, `:466`, `:483`). Fallback stays the bare name. 4. **Register bookkeeping (same commit as the fix):** narrow AD-85 (its item 2 dialogs and item 3 wire-side gap become ported; its item 1 numeric fields — 2-variable templates like `ID_Fellowship_FellowStats` — can now ALSO be fixed by the same primitive, or stay recorded); narrow AD-81 accordingly (its `StringTableMetaLanguage` engine caveat remains for meta-token templates; `ACCharGenData::FormatName` remains open). --- ## Q2 — Refused-drop yellow top-of-screen notification ### 2.1 What ACE sends when a drop is refused `references/ACE/Source/ACE.Server/WorldObjects/Player_Inventory.cs:1371-1470` (`HandleActionDropItem`): | Refusal | What ACE sends | |---|---| | **Attuned item** (`:1389-1393`) | `GameEventInventoryServerSaveFailed(itemGuid, WeenieError.AttunedItem)` — **`0x00A0` with error `0x0426`**. Nothing else. | | Busy/teleporting (`:1373-1378`) | `GameEventWeenieError(YoureTooBusy=0x1D)` + `0x00A0` with error None | | Summoned-pet device (`:1395-1400`) | transient string + `0x00A0` error None | | Item being traded (`:1402-1406`) | `0x00A0` with `TradeItemBeingTraded` | | Teleported mid-chain (`:1412-1416`) | `0x00A0` with `ActionCancelled=0x36` | Layout (`GameEventInventoryServerSaveFailed.cs:7-16`, opcode `GameEventType.cs:18` = `0x00A0`): `uint32 itemGuid; uint32 weenieError`. ACE's own comment: "client doesn't show this error mostly, and defaults to specific error messages, depending on the item name + action" — which is exactly the retail mechanism below. ### 2.2 Where retail displays it, and in what presentation **Dispatch** — event case `0xA0` @0x0055B342 (`pseudo-c:359365-359384`): 1. Prefers the client's own latched `ACCWeenieObject::prevRequestObjectID` over the wire guid, looks the item up, and calls `ACCWeenieObject::ServerSaysAttemptFailed(item, err, 1)`. 2. Then, unless `err ∈ {0x1E, 0x2B, 0x3EF, 0x43E, 0x4CE, 0x4CF, 0x46A}`, also calls `ClientCommunicationSystem::HandleFailureEvent(err, "")` (`@0x00571990` — the 344-row WeenieError→text switch the CH campaign ported). **`0x0426 AttunedItem` has NO case in that switch** (verified in both the decomp and acdream's ported table), so this leg shows nothing for an attuned drop — faithful silence. **Composition** — `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0` (`pseudo-c:409283-409448`) switches on the latched request KIND (`ACCWeenieObject::prevRequest`, enum `InventoryRequest`, `acclient.h:6812-6825`): | `prevRequest` | Base text (`%s` = `GetObjectNameWide`) | Name style | |---|---|---| | `IR_MERGE=1` | `The %s can't be merged` | `NAME_PLURAL` | | `IR_SPLIT=2` | `The %s can't be split` | `NAME_PLURAL` | | `IR_MOVE=3` | `The %s can't be moved` | `NAME_APPROPRIATE` (=2, `acclient.h:6833`) | | `IR_PICK_UP=4` | `The %s can't be picked up` | appropriate | | `IR_PUT_IN_CONTAINER=5` | `The %s can't be put in the container` | appropriate | | **`IR_DROP=6`** | **`The %s can't be dropped`** | appropriate | | `IR_WIELD=7` | `The %s can't be wielded` | appropriate | | `IR_GIVE=9` | `The %s can't be given` | appropriate | then appends an error-code suffix (the decomp's `__return_addr` compares are a BN artifact for the error argument): | Error | Suffix | |---|---| | `0x1D` YoureTooBusy | ` - you're too busy` | | `0x20` IllegalInventoryTransaction | ` - you must control both objects` | | `0x28` Frozen | ` - the item is under someone else's control` | | `0x2A` YouAreTooEncumbered | ` - you are too encumbered` | | `0x36` ActionCancelled | ` - action cancelled` | | `0x37-0x39` ObjectGone/NoObject/CantGetThere | ` - unable to move to object` | | `0x3EE` TheContainerIsClosed | ` - the container is closed` | | anything else (incl. **`0x426`**) | *(no suffix)* | and displays it via **`ECM_UI::SendNotice_DisplayStringInfo(0x1A, si)`** (@0x0058EE07), then clears the latch. Type `0x1A` = `ClientLocal` (`src/AcDream.Core/Chat/RetailLogTextType.cs:60`) is precisely the SpewBox filter: `gmSpewBoxUI`'s `RecvNotice_DisplayFinalStringInfo @0x004D60A0` accepts ONLY `0x1A`, and every chat window is born with that bit CLEARED (`ChatInterface::ChatInterface @0x004F4550`, `m_llTextTypeFilter &= 0xFBFFFFFF` — `docs/research/2026-08-09-chat-retail-color-table.md:351`). So the refused drop is **SpewBox-only** — the transient top-center interface-text area — which acdream renders in the user-pinned retail yellow `0x81C4C8` `(1, 1, 0.247, 1)` (`src/AcDream.App/UI/SpewBoxController.cs:238`, pinned at CH user-gate round 1 side-by-side vs retail; note the CHAT color table's `0x1A` entry is bright red — a different element tree the SpewBox never touches, `SpewBoxController.cs:213-237`). **The latch** — `ACCWeenieObject::RecordRequest @0x0058C220` (`pseudo-c:406362-406371`) stores `(objectId, kind, time)` in three globals; the DROP send site is `ACCWeenieObject::UIAttemptPutIn3D @0x0058D700` (`pseudo-c:407816-407836`): sends `CM_Inventory::Event_DropItem`, latches `IR_DROP` when the item lives in a container (the normal case), `IR_MOVE` if it was already in 3D. One global slot, overwritten per request, cleared on success/failure/timeout. Expected retail behavior for the gate case: drop an attuned item → **`The can't be dropped`** in yellow, top-center, ~5 s. ### 2.3 What acdream is missing (exactly) - **Parse — present.** `GameEvents.ParseInventoryServerSaveFailed` (`src/AcDream.Core.Net/Messages/GameEvents.cs:445-455`) reads `(itemGuid, weenieError)`. - **Display surface — present.** The SpewBox pipeline is complete and user-gated: `RuntimeCommunicationState.AddText` chokepoint routes `RetailLogTextType.ClientLocal` to `SpewBoxState` (`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:96,180-205`), rendered yellow/top-center by `SpewBoxController`. - **WeenieError table — present.** `WeenieErrorMessages.Resolve` (`src/AcDream.Core/Chat/WeenieErrorMessages.cs:172-183`, 344 rows, no `0x426` row — faithful) already serves the UseDone path (`GameEventWiring.cs:752+`). - **Routing — MISSING.** The `0x00A0` handler (`src/AcDream.Core.Net/GameEventWiring.cs:726-740`) does ONLY the B-Drag optimistic rollback (`ClientObjectTable.RejectMove`, `src/AcDream.Core/Items/ClientObjectTable.cs:698-703`) plus a console line. Every `MoveRequestFailed` subscriber is state-cleanup only (`InventoryController.cs:338-345`, `AutoWieldController.cs:400-409`, `InventoryWorldDropProjectionController.cs:100`, `InventoryTransactionState.cs:72`). **No user-visible text is produced anywhere on this path.** - **Request-kind latch — MISSING.** Nothing records "the last inventory request was a DROP of guid X" (retail's `prevRequest*` trio), so the "can't be dropped" verb cannot be chosen today. acdream's drop send is `WorldSession.SendDropItem` (`src/AcDream.Core.Net/WorldSession.cs:2557-2561`, wired at `InteractionRetainedUiComposition.cs:322`). - **Composer — MISSING.** No port of `ServerSaysAttemptFailed`'s verb table + suffix map. - **`HandleFailureEvent` leg — MISSING on this path.** The 0x00A0 route never consults `WeenieErrorMessages` (harmless for `0x426`, wrong for codes that DO have rows and are not in the exclusion set). ### 2.4 Implementation recipe (Q2) 1. **Latch the request kind** — a Runtime-owned single-slot `(itemGuid, InventoryRequest kind, time)` mirror of retail's `RecordRequest @0x0058C220`. Natural home: `RuntimeInventoryState` (J4.2 already owns the one-request-at-a-time gate). Write it at every inventory send site (drop / pickup / wield / give / merge / split / move / put-in-container); overwrite-per-request; clear on the success echo and in the 0x00A0 handler after composing (retail clears at `@0x0058EE43-0x0058EE63`). 2. **On `0x00A0`** (after the existing `RejectMove`): - If a latch exists (prefer the latched guid over the wire guid, exactly retail's `prevRequestObjectID` preference @0x0055B361) and the item resolves in `ClientObjectTable`: compose `"The {name} can't be {verb}"` from the §2.2 verb table + suffix map, and route `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` → lands in the SpewBox, yellow, top-center. If no latch/item: no text (retail shows nothing — do NOT invent a fallback line). - Then, mirroring the dispatcher, if `err ∉ {0x1E, 0x2B, 0x3EF, 0x43E, 0x4CE, 0x4CF, 0x46A}`, run `WeenieErrorMessages.Resolve(err, null)` and display per its returned `RetailLogTextType` (null text → silent, which covers `0x426`). 3. **Name style:** retail uses `GetObjectNameWide(NAME_APPROPRIATE)` (plural-aware article-free form; `NAME_PLURAL` for merge/split). If the first pass uses `ClientObject.Name` raw, file the (tiny) divergence row for plural/appropriate handling in the same commit. 4. **Layer note:** the composer needs the item name (Core `ClientObjectTable`) and the Runtime chokepoint — wire it where both are borrowed (the App/Runtime composition that already owns the 0x00A0 consumer), not inside `GameEventWiring`'s Core.Net registrar body, to keep Code Structure Rule 2 intact. --- ## Verification checklist for the fix session - Fellowship invite (two-client): recruit's dialog reads ` has invited you to join their fellowship. Do you accept?` - Incoming swear (two-client): patron's dialog reads ` would like to swear allegiance to you. Do you accept?` - Local swear click: `Do you wish to swear to ?` (full sentence). - Drop an attuned item: yellow top-center `The can't be dropped`, no chat-transcript line, dialog-free. - Register rows AD-81/AD-85 narrowed in the same commit; new row only if the name-style approximation (recipe Q2 step 3) ships.