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

@ -77,6 +77,18 @@ public sealed class InventoryTransactionState : IDisposable
public event Action? StateChanged;
public event Action<PendingInventoryRequest>? RequestCompleted;
/// <summary>
/// Fires when the pending request is cleared by an
/// InventoryServerSaveFailed (0x00A0) response, carrying the request plus
/// the wire WeenieError. This is the seam retail's
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c> consumes:
/// it reads <c>prevRequest</c> to pick the "can't be &lt;verb&gt;" text
/// before <c>RecordResponse</c> clears the latch. Fires after
/// <see cref="RequestCompleted"/> for the same request.
/// </summary>
public event Action<PendingInventoryRequest, uint>? RequestFailed;
public event Action? ObjectTableCleared;
public ClientObjectTable Objects => _objects;
@ -278,6 +290,7 @@ public sealed class InventoryTransactionState : IDisposable
_busyCount = 0;
StateChanged = null;
RequestCompleted = null;
RequestFailed = null;
ObjectTableCleared = null;
}
@ -310,10 +323,18 @@ public sealed class InventoryTransactionState : IDisposable
CompleteInventoryResponse(move.ItemId, move.Item);
}
private void OnMoveFailed(MoveRequestFailure failure) =>
CompleteInventoryResponse(
failure.ItemId,
_objects.Get(failure.ItemId));
private void OnMoveFailed(MoveRequestFailure failure)
{
// Match by the wire guid. Retail's dispatcher (case 0xA0 @ 0x0055B342)
// PREFERS the latched prevRequestObjectID over the wire guid, but ACE
// always sends the item guid, so requiring the match is equivalent —
// and it protects a stale latch from mislabeling an unrelated failure.
if (CompleteInventoryResponse(failure.ItemId, _objects.Get(failure.ItemId))
is { } failed)
{
Dispatch(RequestFailed, failed, failure.WeenieError);
}
}
private void OnObjectRemoved(ClientObject item) =>
CompleteInventoryResponse(item.ObjectId, item);
@ -321,7 +342,7 @@ public sealed class InventoryTransactionState : IDisposable
private void OnStackSizeUpdated(ClientObject item) =>
CompleteInventoryResponse(item.ObjectId, item);
private void CompleteInventoryResponse(
private PendingInventoryRequest? CompleteInventoryResponse(
uint itemId,
ClientObject? identity)
{
@ -329,7 +350,7 @@ public sealed class InventoryTransactionState : IDisposable
|| request.ItemId != itemId
|| !MatchesIdentity(request.ItemIdentity, itemId, identity))
{
return;
return null;
}
// RecordResponse clears prevRequest before ItemList receives the
@ -338,6 +359,7 @@ public sealed class InventoryTransactionState : IDisposable
_pendingRequest = null;
Dispatch(RequestCompleted, request);
DispatchStateChanged();
return request;
}
private bool MatchesIdentity(
@ -398,6 +420,23 @@ public sealed class InventoryTransactionState : IDisposable
}
}
private void Dispatch<T1, T2>(Action<T1, T2>? listeners, T1 first, T2 second)
{
if (listeners is null)
return;
foreach (Action<T1, T2> listener in listeners.GetInvocationList())
{
try
{
listener(first, second);
}
catch (Exception error)
{
RecordDispatchFailure(error);
}
}
}
private void RecordDispatchFailure(Exception error)
{
Interlocked.Increment(ref _dispatchFailureCount);