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>
This commit is contained in:
Erik 2026-08-13 21:10:42 +02:00
parent fc62cb6397
commit 67fe754dd6
15 changed files with 1133 additions and 50 deletions

View file

@ -354,6 +354,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
stackSplitQuantity: d.StackSplitQuantity,
systemMessage:
text => d.Communication.AddText(text, RetailLogTextType.ClientLocal),
// ServerSaysAttemptFailed / HandleFailureEvent refusal lines
// (0x00A0) — typed so per-code routing (SpewBox vs chat) follows
// WeenieErrorMessages' resolved destination.
interfaceText: (text, type) => d.Communication.AddText(text, type),
sendPutItemInContainer: (item, container, placement) =>
session.CurrentSession?.SendPutItemInContainer(
item,

View file

@ -13,6 +13,7 @@ public sealed class GameplayConfirmationController : IDisposable
{
private readonly RetailDialogFactory _dialogs;
private readonly Action<uint, uint, bool> _sendResponse;
private readonly Func<uint, string, string?>? _composeMessage;
private uint _dialogContext;
private uint _serverType;
private uint _serverContext;
@ -20,10 +21,12 @@ public sealed class GameplayConfirmationController : IDisposable
public GameplayConfirmationController(
RetailDialogFactory dialogs,
Action<uint, uint, bool> sendResponse)
Action<uint, uint, bool> sendResponse,
Func<uint, string, string?>? composeMessage = null)
{
_dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
_sendResponse = sendResponse ?? throw new ArgumentNullException(nameof(sendResponse));
_composeMessage = composeMessage;
_dialogs.DialogClosed += OnDialogClosed;
}
@ -46,9 +49,15 @@ public sealed class GameplayConfirmationController : IDisposable
if (_dialogContext != 0u)
return false;
// Types 1/4 arrive as ACE's bare player name; retail's own client
// wraps it through the StringInfo template mechanism
// (RecvNotice_SwearAllegiance / gmFellowshipUI's FellowshipRequest —
// the injected composer owns the resolve). A null compose falls back
// to the bare wire message rather than invented English.
string message = request.Type is 2u or 3u or 5u or 6u
? request.Message + " Continue?"
: request.Message;
: _composeMessage?.Invoke(request.Type, request.Message)
?? request.Message;
var data = RetailDialogData.Confirmation(message)
.Set(RetailDialogProperty.ElementAttribute40, true);
_dialogContext = _dialogs.MakeDialog(data);

View file

@ -1,4 +1,5 @@
using System;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
@ -59,6 +60,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly StackSplitQuantityState? _stackSplitQuantity;
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly Action<string>? _systemMessage;
private readonly Action<string, RetailLogTextType>? _interfaceText;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true
@ -115,7 +117,8 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, ItemUseRequestReservation>? requestUse = null,
Func<uint, uint, int, uint, bool>? sendBuy = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null)
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null,
Action<string, RetailLogTextType>? interfaceText = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -144,6 +147,7 @@ public sealed class ItemInteractionController : IDisposable
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage;
_interfaceText = interfaceText;
_requestUse = requestUse;
_sendBuy = sendBuy;
_sendBuyAll = sendBuyAll;
@ -171,7 +175,9 @@ public sealed class ItemInteractionController : IDisposable
_interactionState.Changed += OnInteractionModeChanged;
_transactions.StateChanged += OnTransactionStateChanged;
_transactions.RequestCompleted += OnInventoryRequestCompleted;
_transactions.RequestFailed += OnInventoryRequestFailed;
_transactions.ObjectTableCleared += OnInventoryObjectsCleared;
_objects.MoveRequestFailed += OnMoveRequestFailedNotice;
}
public event Action? StateChanged;
@ -1338,12 +1344,70 @@ public sealed class ItemInteractionController : IDisposable
_pendingBackpackPlacement = null;
}
/// <summary>
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c>: the
/// server rejected the latched inventory request — compose
/// "The &lt;item&gt; can't be &lt;verb&gt;" and route it as LogTextType
/// 0x1A (ClientLocal, the SpewBox-only channel). No resolvable item or no
/// latched kind → no text, exactly retail's silence.
/// </summary>
private void OnInventoryRequestFailed(
PendingInventoryRequest request,
uint weenieError)
{
if (_interfaceText is null)
return;
ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId);
if (item is null)
return;
// NAME_PLURAL for merge/split, NAME_APPROPRIATE otherwise (the
// ServerSaysAttemptFailed name-style column); a wire-omitted plural
// falls back to the appropriate form.
bool plural = request.Kind
is InventoryRequestKind.Merge
or InventoryRequestKind.SplitToContainer
or InventoryRequestKind.SplitToWorld;
string name = plural && !string.IsNullOrEmpty(item.PluralName)
? item.PluralName
: item.GetAppropriateName();
if (string.IsNullOrEmpty(name))
return;
if (InventoryFailureMessages.Compose(request.Kind, name, weenieError)
is { } text)
{
_interfaceText(text, RetailLogTextType.ClientLocal);
}
}
/// <summary>
/// The 0x00A0 dispatcher's second leg (<c>case 0xA0 @ 0x0055B342</c>):
/// unless the error is in the exclusion set, the generic
/// <c>HandleFailureEvent</c> table also runs — independent of whether a
/// request was latched. Codes without a table row (0x426 AttunedItem
/// among them) resolve to null text and stay silent.
/// </summary>
private void OnMoveRequestFailedNotice(MoveRequestFailure failure)
{
if (_interfaceText is null
|| failure.WeenieError == 0u
|| InventoryFailureMessages.SuppressesGenericFailureText(
failure.WeenieError))
{
return;
}
var (text, type) = WeenieErrorMessages.Resolve(failure.WeenieError, null);
if (text is not null)
_interfaceText(text, type);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
_objects.MoveRequestFailed -= OnMoveRequestFailedNotice;
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestFailed -= OnInventoryRequestFailed;
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;

View file

@ -61,6 +61,60 @@ public sealed class DatStringResolver
: null;
}
/// <summary>Retail's variable-name hash for <c>PLAYER</c> (0x05506DA2) —
/// the single substitution slot every social confirmation template uses.</summary>
public static readonly uint PlayerVariable = ComputeHash("PLAYER");
/// <summary>
/// Composes a templated StringTable entry: N (or N+1) literal fragments
/// interleaved with N named variables, keyed by the entry-key hash.
/// </summary>
/// <remarks>
/// Exact port of <c>StringTable::GetString @ 0x004300D0</c>'s
/// no-metalanguage branch <c>@ 0x004303B7</c>: each fragment is appended,
/// then the variable in the same slot (resolved through
/// <paramref name="variables"/>, keyed by <see cref="ComputeHash"/> of the
/// authored variable name; a missing variable substitutes the empty
/// string, as retail does). This is NOT a
/// <c>StringTableMetaLanguage::RenderString</c> port — callers own
/// keeping it to token-free templates (register row AD-81's scope note).
/// </remarks>
public string? ResolveTemplate(
uint tableId,
string key,
IReadOnlyDictionary<uint, string> variables)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(variables);
if (tableId == 0u)
return null;
if (!_tables.TryGetValue(tableId, out StringTable? table))
{
table = _dats.Get<StringTable>(tableId);
_tables[tableId] = table;
}
if (table is null
|| !table.Strings.TryGetValue(ComputeHash(key), out var entry)
|| entry.Strings.Count == 0)
return null;
var composed = new System.Text.StringBuilder();
for (int i = 0; i < entry.Strings.Count; i++)
{
composed.Append(entry.Strings[i].Value);
// Variables are stored as the pre-computed name hashes (the same
// compute_str_hash space PlayerVariable lives in).
if (i < entry.Variables.Count
&& variables.TryGetValue(entry.Variables[i], out string? value))
{
composed.Append(value);
}
}
return composed.ToString();
}
/// <summary>
/// Exact retail ELF-style string hash used for StringInfo keys.
/// Ported line-for-line from <c>compute_str_hash @ 0x00413110</c>.

View file

@ -183,7 +183,12 @@ public sealed class SocialAllegiancePageController
Func<uint, uint, UiElement?> TemplateResolver,
Func<uint, uint, string?> ResolveString,
Func<uint, string?> ResolveWorldObjectName,
Func<string, Action<bool>, uint> ShowConfirmation);
Func<string, Action<bool>, uint> ShowConfirmation,
// (templateKey, playerName) -> the composed confirmation sentence via
// StringTable::GetString's fragment/PLAYER-variable interleave
// (DatStringResolver.ResolveTemplate). Null delegate or null result →
// the caller falls back to the bare name, never invented English.
Func<string, string, string?>? ResolvePlayerTemplate = null);
private readonly record struct VassalRowWidgets(
UiText? Name,
@ -216,12 +221,13 @@ public sealed class SocialAllegiancePageController
/// <summary>Bind-time-resolved (never per-tick — same discipline every
/// other <c>DatStringResolver</c> consumer in this codebase follows).
/// Null when resolution failed — the affected widget then keeps its
/// import-time text/caption rather than showing invented English.</summary>
/// import-time text/caption rather than showing invented English.
/// The swear/break/kick confirmation SENTENCES are not latched here:
/// they need the target's name, so they compose at click time through
/// <see cref="Bindings.ResolvePlayerTemplate"/> (retail's own
/// <c>MakeXxxConfirmationDialog</c> shape — 2026-08-13 AD-85 fix).</summary>
private readonly string? _monarchLabelCaption;
private readonly string? _patronSlashMonarchLabelCaption;
private readonly string? _swearConfirmationTemplate;
private readonly string? _breakConfirmationTemplate;
private readonly string? _kickConfirmationTemplate;
private readonly Dictionary<uint, VassalRowWidgets> _rows = new();
private readonly HashSet<uint> _vassalGuids = new();
@ -262,10 +268,7 @@ public sealed class SocialAllegiancePageController
UiButton? breakButton,
UiButton? kickButton,
string? monarchLabelCaption,
string? patronSlashMonarchLabelCaption,
string? swearConfirmationTemplate,
string? breakConfirmationTemplate,
string? kickConfirmationTemplate)
string? patronSlashMonarchLabelCaption)
{
_bindings = bindings;
_selfName = selfName;
@ -287,9 +290,6 @@ public sealed class SocialAllegiancePageController
_kickButton = kickButton;
_monarchLabelCaption = monarchLabelCaption;
_patronSlashMonarchLabelCaption = patronSlashMonarchLabelCaption;
_swearConfirmationTemplate = swearConfirmationTemplate;
_breakConfirmationTemplate = breakConfirmationTemplate;
_kickConfirmationTemplate = kickConfirmationTemplate;
}
public static SocialAllegiancePageController? Bind(UiElement pageRoot, Bindings bindings)
@ -359,15 +359,6 @@ public sealed class SocialAllegiancePageController
string? patronSlashMonarchLabelCaption = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_PatronSlashMonarchLabel"));
// AD-85: unsubstituted retail template text, used verbatim (never
// blended with an invented sentence) — see class doc.
string? swearConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_SwearConfirmation"));
string? breakConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_BreakConfirmation"));
string? kickConfirmationTemplate = bindings.ResolveString(
StringTableId, DatStringResolver.ComputeHash("ID_Allegiance_KickConfirmation"));
var controller = new SocialAllegiancePageController(
bindings,
selfName, selfFollowers, selfRank,
@ -375,8 +366,7 @@ public sealed class SocialAllegiancePageController
monarchIsPatronSubBlock, monarchExperiencePassedUp,
patronField, patronName, patronExperiencePassedUp,
vassalListBox, ignoreRequestsCheckbox, swearButton, breakButton, kickButton,
monarchLabelCaption, patronSlashMonarchLabelCaption,
swearConfirmationTemplate, breakConfirmationTemplate, kickConfirmationTemplate);
monarchLabelCaption, patronSlashMonarchLabelCaption);
controller.WireButtons();
controller.WireCheckbox();
@ -450,7 +440,12 @@ public sealed class SocialAllegiancePageController
string? name = _bindings.ResolveWorldObjectName(targetGuid);
if (string.IsNullOrEmpty(name)) return;
string message = _swearConfirmationTemplate ?? name;
// "Do you wish to swear to <target>?" — StringTable template
// ID_Allegiance_SwearConfirmation with the PLAYER slot filled at
// click time (retail's MakeSwearConfirmationDialog). Null resolve →
// the bare name, never invented English.
string message = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_SwearConfirmation", name) ?? name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Swear(targetGuid);
@ -465,7 +460,8 @@ public sealed class SocialAllegiancePageController
uint selfGuid = _bindings.LocalPlayerGuid();
if (_bindings.Patron(selfGuid) is not { } patron) return;
string message = _breakConfirmationTemplate ?? patron.Name;
string message = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_BreakConfirmation", patron.Name) ?? patron.Name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Break(patron.CharacterId);
@ -483,7 +479,8 @@ public sealed class SocialAllegiancePageController
if (_bindings.Member(_selectedVassalGuid) is not { } vassal) return;
uint vassalGuid = _selectedVassalGuid;
string message = _kickConfirmationTemplate ?? vassal.Name;
string message = _bindings.ResolvePlayerTemplate?.Invoke(
"ID_Allegiance_KickConfirmation", vassal.Name) ?? vassal.Name;
_bindings.ShowConfirmation(message, accepted =>
{
if (accepted) _bindings.Kick(vassalGuid);

View file

@ -2858,7 +2858,24 @@ public sealed class RetailUiRuntime : IDisposable
// an allegiance member, so the allegiance-profile accessors
// above cannot name them).
ResolveWorldObjectName: guid => _bindings.Inventory.Objects.Get(guid)?.GetAppropriateName(),
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed)),
ShowConfirmation: (message, completed) => ShowConfirmation(message, completed),
// Swear/Break/Kick confirmation sentences: the 0x23000001
// templates' PLAYER slot filled at click time (2026-08-13
// confirm/weenie-error research §1.4 — the AD-85 dangling
// "Do you wish to swear to " fix).
ResolvePlayerTemplate: (key, playerName) =>
{
lock (_bindings.Assets.DatLock)
{
return fellowshipStrings.ResolveTemplate(
0x23000001u,
key,
new Dictionary<uint, string>
{
[Layout.DatStringResolver.PlayerVariable] = playerName,
});
}
}),
Friends: _bindings.Social.Friends,
Squelch: _bindings.Social.Squelch,
TemplateResolver: TemplateResolver,
@ -2978,9 +2995,37 @@ public sealed class RetailUiRuntime : IDisposable
}
DialogFactory = new RetailDialogFactory(Host.Root, CreateLayout);
// Types 1/4 carry ACE's bare player name; retail's client wraps it
// via the local StringTable templates (single PLAYER variable,
// token-free — 2026-08-13 confirm/weenie-error research §1).
var confirmationStrings = new DatStringResolver(_bindings.Assets.Dats);
string? ComposeConfirmation(uint type, string bareName)
{
string? key = type switch
{
// gmAllegianceUI::RecvNotice_SwearAllegiance
1u => "ID_Allegiance_AcceptSwearConfirmation",
// gmFellowshipUI's incoming FellowshipRequest
4u => "ID_Fellowship_FellowshipRequest",
_ => null,
};
if (key is null)
return null;
lock (_bindings.Assets.DatLock)
{
return confirmationStrings.ResolveTemplate(
0x23000001u,
key,
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = bareName,
});
}
}
_gameplayConfirmationController = new GameplayConfirmationController(
DialogFactory,
_bindings.Confirmations.SendResponse);
_bindings.Confirmations.SendResponse,
ComposeConfirmation);
_itemConfirmationController = new RetailItemConfirmationController(
DialogFactory,
ItemInteraction);