acdream/docs/research/2026-08-13-confirm-and-weenie-error-display.md
Erik 67fe754dd6 fix: social gate round 2, part 2 - confirmation-dialog sentences + the
refused-drop yellow notice

Item 4 (confirmation dialogs missing text + names): the missing retail
mechanism was StringTable template substitution - an entry is N+1 literal
fragments interleaved with N named variables, composed by
StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7).
ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps
it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2,
the exact compute_str_hash space; Chorizite stores the variable hashes
directly):

- Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 ->
  ID_Allegiance_AcceptSwearConfirmation, injected into
  GameplayConfirmationController; null resolve falls back to the bare
  wire message, never invented English. The 2/3/5/6 " Continue?" family
  never consults the composer.
- Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed
  the dangling "Do you wish to swear to ") is replaced by click-time
  ResolveTemplate with the target's name.

All five templates verified token-free in the installed DAT - this is
NOT a StringTableMetaLanguage port (AD-81's engine caveat stands).

Item 5 (refused drop shows nothing; retail shows yellow top-center
text): the prevRequest latch was ALREADY ported (InventoryTransactionState);
what was missing was the consumer. InventoryTransactionState now raises
RequestFailed(request, weenieError) when a 0x00A0 clears the latch;
ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's
"The <item> can't be <verb>" (verb table + suffix map ported verbatim in
Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes
it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow
top-center line. The dispatcher's second leg (@0x0055B342) also runs:
outside the 7-code exclusion set, WeenieErrorMessages resolves per-code
text/destination; 0x426 AttunedItem has no row in either place beyond
the verb line - faithful single-line output.

Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the
token-free interleave is now ported; meta-token engine + FormatName
remain), AD-93 filed (wire-guid-match vs retail's latched-guid
preference; no Move/Wield latch kinds).

Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate
(constructed StringTable fixtures), +1 composer injection, +1 end-to-end
refused-drop line. Core 4,697/1 skip, App 4,983/3 skips.

Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:10:42 +02:00

22 KiB

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:<line> @<address>); 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:126EnqueueSend(new Confirmation_Fellowship(inviter.Guid, newMember.Guid), inviter.Name) the inviter's name
1 SwearAllegiance references/ACE/Source/ACE.Server/WorldObjects/Player_Allegiance.cs:90EnqueueSend(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 1CM_Allegiance::SendNotice_SwearAllegianceRequest(text, ctx) (@0x006A7420)
  • case 4CM_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(<STRING_ID>, 0x10000001);   // table enum → StringTable DID
si.AddVariable_String(<VAR_KEY>, 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 <STRING_ID> and <VAR_KEY> 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): <inviterName> has invited you to join their fellowship. Do you accept?
  • Incoming swear (patron sees): <vassalName> would like to swear allegiance to you. Do you accept?
  • Local swear (vassal clicks Swear): Do you wish to swear to <targetName>? (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-316RetailUiRuntime.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 StringTables and owns ComputeHash):

    // 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<uint, string> 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<uint,string,string?> — 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.

CompositionACCWeenieObject::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 &= 0xFBFFFFFFdocs/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 latchACCWeenieObject::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 <item name> 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 <inviter> has invited you to join their fellowship. Do you accept?
  • Incoming swear (two-client): patron's dialog reads <vassal> would like to swear allegiance to you. Do you accept?
  • Local swear click: Do you wish to swear to <target>? (full sentence).
  • Drop an attuned item: yellow top-center The <item> 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.