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>
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: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(<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 fromGetObjectName(NAME_APPROPRIATE)— lane Cdocs/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:
-
Add a template-substituting resolve to
DatStringResolver(it already cachesStringTables and ownsComputeHash):// 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"); // 0x05506DA2Guard: 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
StringTableMetaLanguageport — AD-81's scope note stands. -
Server-driven dialogs (types 1/4): where
RetailUiRuntime.HandleConfirmationRequest(orGameplayConfirmationController.HandleRequestvia an injectedFunc<uint,string,string?>— the controller is constructed atRetailUiRuntime.cs:2981-2983where_bindings.Assets.Datsis in scope, same as the 20 existingnew 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.
- type 4 →
-
Local swear/break/kick: replace the bind-time
ResolveString(...)-fragment-0 latch with the sameResolveTemplate(0x23000001, key, {PLAYER: targetName})at click time (the name is already fetched:SocialAllegiancePageController.cs:450,:466,:483). Fallback stays the bare name. -
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 (itsStringTableMetaLanguageengine caveat remains for meta-token templates;ACCharGenData::FormatNameremains 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):
- Prefers the client's own latched
ACCWeenieObject::prevRequestObjectIDover the wire guid, looks the item up, and callsACCWeenieObject::ServerSaysAttemptFailed(item, err, 1). - Then, unless
err ∈ {0x1E, 0x2B, 0x3EF, 0x43E, 0x4CE, 0x4CF, 0x46A}, also callsClientCommunicationSystem::HandleFailureEvent(err, "")(@0x00571990— the 344-row WeenieError→text switch the CH campaign ported).0x0426 AttunedItemhas 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 <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.AddTextchokepoint routesRetailLogTextType.ClientLocaltoSpewBoxState(src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:96,180-205), rendered yellow/top-center bySpewBoxController. - WeenieError table — present.
WeenieErrorMessages.Resolve(src/AcDream.Core/Chat/WeenieErrorMessages.cs:172-183, 344 rows, no0x426row — faithful) already serves the UseDone path (GameEventWiring.cs:752+). - Routing — MISSING. The
0x00A0handler (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. EveryMoveRequestFailedsubscriber 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 isWorldSession.SendDropItem(src/AcDream.Core.Net/WorldSession.cs:2557-2561, wired atInteractionRetainedUiComposition.cs:322). - Composer — MISSING. No port of
ServerSaysAttemptFailed's verb table- suffix map.
HandleFailureEventleg — MISSING on this path. The 0x00A0 route never consultsWeenieErrorMessages(harmless for0x426, wrong for codes that DO have rows and are not in the exclusion set).
2.4 Implementation recipe (Q2)
- Latch the request kind — a Runtime-owned single-slot
(itemGuid, InventoryRequest kind, time)mirror of retail'sRecordRequest @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). - On
0x00A0(after the existingRejectMove):- If a latch exists (prefer the latched guid over the wire guid, exactly
retail's
prevRequestObjectIDpreference @0x0055B361) and the item resolves inClientObjectTable: compose"The {name} can't be {verb}"from the §2.2 verb table + suffix map, and routeRuntimeCommunicationState.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}, runWeenieErrorMessages.Resolve(err, null)and display per its returnedRetailLogTextType(null text → silent, which covers0x426).
- If a latch exists (prefer the latched guid over the wire guid, exactly
retail's
- Name style: retail uses
GetObjectNameWide(NAME_APPROPRIATE)(plural-aware article-free form;NAME_PLURALfor merge/split). If the first pass usesClientObject.Nameraw, file the (tiny) divergence row for plural/appropriate handling in the same commit. - 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 insideGameEventWiring'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.