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:
parent
fc62cb6397
commit
67fe754dd6
15 changed files with 1133 additions and 50 deletions
File diff suppressed because one or more lines are too long
405
docs/research/2026-08-13-confirm-and-weenie-error-display.md
Normal file
405
docs/research/2026-08-13-confirm-and-weenie-error-display.md
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
# 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 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<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.
|
||||
|
||||
**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.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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <item> can't be <verb>" 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;
|
||||
|
|
|
|||
|
|
@ -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>.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
81
src/AcDream.Core/Chat/InventoryFailureMessages.cs
Normal file
81
src/AcDream.Core/Chat/InventoryFailureMessages.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the client-side "The <item> can't be <verb>" refusal
|
||||
/// line retail shows when the server rejects an inventory request
|
||||
/// (InventoryServerSaveFailed 0x00A0).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exact port of <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c>:
|
||||
/// the base text switches on the latched request kind
|
||||
/// (<c>ACCWeenieObject::prevRequest</c>, enum <c>InventoryRequest</c>,
|
||||
/// <c>acclient.h:6812-6825</c>), then an error-code suffix is appended, and
|
||||
/// the result is displayed via <c>ECM_UI::SendNotice_DisplayStringInfo(0x1A)</c>
|
||||
/// — LogTextType 0x1A (<see cref="RetailLogTextType.ClientLocal"/>), the
|
||||
/// SpewBox-only channel. The event dispatcher (<c>case 0xA0 @ 0x0055B342</c>)
|
||||
/// additionally routes most errors through
|
||||
/// <c>ClientCommunicationSystem::HandleFailureEvent</c>; the exclusion set it
|
||||
/// checks first lives in <see cref="SuppressesGenericFailureText"/>.
|
||||
/// </remarks>
|
||||
public static class InventoryFailureMessages
|
||||
{
|
||||
/// <summary>
|
||||
/// The refusal line for a failed inventory request, or null when the
|
||||
/// request kind has no retail verb (retail shows nothing rather than an
|
||||
/// invented sentence). <paramref name="itemName"/> must already be the
|
||||
/// retail-appropriate display name (plural for merge/split —
|
||||
/// <c>NAME_PLURAL</c>; appropriate otherwise).
|
||||
/// </summary>
|
||||
public static string? Compose(
|
||||
InventoryRequestKind kind,
|
||||
string itemName,
|
||||
uint weenieError)
|
||||
{
|
||||
// ServerSaysAttemptFailed's verb switch. acdream has no latched kind
|
||||
// for retail's IR_MOVE ("moved") or IR_WIELD ("wielded") today —
|
||||
// wields ride AutoWieldController without the single-request gate —
|
||||
// so those rows are absent rather than guessed onto a wrong kind.
|
||||
string? verb = kind switch
|
||||
{
|
||||
InventoryRequestKind.Merge => "merged",
|
||||
InventoryRequestKind.SplitToContainer => "split",
|
||||
InventoryRequestKind.SplitToWorld => "split",
|
||||
InventoryRequestKind.Pickup => "picked up",
|
||||
InventoryRequestKind.PutInContainer => "put in the container",
|
||||
InventoryRequestKind.DropToWorld => "dropped",
|
||||
InventoryRequestKind.Give => "given",
|
||||
_ => null,
|
||||
};
|
||||
if (verb is null)
|
||||
return null;
|
||||
|
||||
return $"The {itemName} can't be {verb}{Suffix(weenieError)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ServerSaysAttemptFailed's error suffix map; every other code —
|
||||
/// including 0x426 AttunedItem — gets no suffix.
|
||||
/// </summary>
|
||||
private static string Suffix(uint weenieError) => weenieError switch
|
||||
{
|
||||
0x1Du => " - you're too busy",
|
||||
0x20u => " - you must control both objects",
|
||||
0x28u => " - the item is under someone else's control",
|
||||
0x2Au => " - you are too encumbered",
|
||||
0x36u => " - action cancelled",
|
||||
0x37u or 0x38u or 0x39u => " - unable to move to object",
|
||||
0x3EEu => " - the container is closed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The 0x00A0 dispatcher's exclusion set (<c>@ 0x0055B342</c>): these
|
||||
/// errors skip the <c>HandleFailureEvent</c> leg entirely (their text, if
|
||||
/// any, is owned elsewhere).
|
||||
/// </summary>
|
||||
public static bool SuppressesGenericFailureText(uint weenieError)
|
||||
=> weenieError is 0x1Eu or 0x2Bu or 0x3EFu or 0x43Eu or 0x4CEu
|
||||
or 0x4CFu or 0x46Au;
|
||||
}
|
||||
|
|
@ -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 <verb>" 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);
|
||||
|
|
|
|||
|
|
@ -41,9 +41,12 @@ public sealed class GameplayConfirmationControllerTests
|
|||
/// client reads neither option bit on the invite path;
|
||||
/// <c>RetailUiRuntime.HandleConfirmationRequest</c> now routes every
|
||||
/// type, including 4, straight here). Type 4 is NOT in the
|
||||
/// 2/3/5/6 " Continue?"-suffix set, so the message renders verbatim —
|
||||
/// matching <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s
|
||||
/// case-4 arm, which is a single call with no text transformation.
|
||||
/// 2/3/5/6 " Continue?"-suffix set, so with no composer injected the
|
||||
/// message renders verbatim — matching
|
||||
/// <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s case-4 arm.
|
||||
/// (Production now injects the StringTable template composer — see
|
||||
/// <see cref="InjectedComposerWrapsTypes1And4_AndNeverTouchesContinueFamily"/>;
|
||||
/// this test remains the null-composer fallback contract.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FellowshipInviteRequest_Type4_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
|
||||
|
|
@ -72,6 +75,65 @@ public sealed class GameplayConfirmationControllerTests
|
|||
Assert.Equal(0u, controller.ActiveDialogContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-13 social gate round 2 (the AD-85 narrowing): with an
|
||||
/// injected composer, types 1/4 render the StringTable-composed
|
||||
/// sentence instead of ACE's bare name; the 2/3/5/6 " Continue?" family
|
||||
/// never consults the composer; and a null compose result falls back to
|
||||
/// the bare wire message.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InjectedComposerWrapsTypes1And4_AndNeverTouchesContinueFamily()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
var composed = new List<uint>();
|
||||
using var controller = new GameplayConfirmationController(
|
||||
factory,
|
||||
(_, _, _) => { },
|
||||
(type, bareName) =>
|
||||
{
|
||||
composed.Add(type);
|
||||
return type == 4u
|
||||
? bareName
|
||||
+ " has invited you to join their fellowship. Do you accept?"
|
||||
: null;
|
||||
});
|
||||
|
||||
Assert.True(controller.HandleRequest(
|
||||
new GameEvents.CharacterConfirmationRequest(4u, 7u, "Alice")));
|
||||
Assert.Equal(
|
||||
"Alice has invited you to join their fellowship. Do you accept?",
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
||||
.Select(static line => line.Text)));
|
||||
Assert.IsType<UiButton>(shown.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
// Null compose result (type 1 here) → the bare wire message.
|
||||
Assert.True(controller.HandleRequest(
|
||||
new GameEvents.CharacterConfirmationRequest(1u, 8u, "Bob")));
|
||||
Assert.Equal(
|
||||
"Bob",
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
||||
.Select(static line => line.Text)));
|
||||
Assert.IsType<UiButton>(shown.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
// The " Continue?" family bypasses the composer entirely.
|
||||
Assert.True(controller.HandleRequest(
|
||||
new GameEvents.CharacterConfirmationRequest(2u, 9u, "Raise this skill?")));
|
||||
Assert.Equal(
|
||||
"Raise this skill? Continue?",
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider()
|
||||
.Select(static line => line.Text)));
|
||||
Assert.Equal([4u, 1u], composed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA5, item 3: verifies the allegiance-swear
|
||||
/// confirmation (<c>ConfirmationType.AllegianceSwear</c>, type 1 —
|
||||
|
|
@ -83,12 +145,12 @@ public sealed class GameplayConfirmationControllerTests
|
|||
/// type 1 at all, but FA5's own contract calls for this explicit check
|
||||
/// since <c>SocialAllegiancePageController</c> is the new panel that
|
||||
/// makes this path reachable). Type 1 is NOT in the 2/3/5/6 " Continue?"
|
||||
/// suffix set, so the message renders verbatim — ACE's own type-1
|
||||
/// message is the target's BARE name (lane C §6.4:
|
||||
/// <c>Player_Allegiance.cs:91</c>/<c>ConfirmationManager.cs:38</c>), not
|
||||
/// a full sentence, which this test's message deliberately mirrors
|
||||
/// rather than inventing retail's unported <c>StringInfo</c>-wrapped
|
||||
/// sentence (AD-85).
|
||||
/// suffix set, so with no composer injected the message renders verbatim
|
||||
/// — ACE's own type-1 message is the target's BARE name (lane C §6.4:
|
||||
/// <c>Player_Allegiance.cs:91</c>/<c>ConfirmationManager.cs:38</c>).
|
||||
/// (Production now injects the StringTable template composer that wraps
|
||||
/// the name into retail's full sentence — the 2026-08-13 AD-85
|
||||
/// narrowing; this test remains the null-composer fallback contract.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
|
@ -41,6 +42,7 @@ public sealed class ItemInteractionControllerTests
|
|||
public bool SendSellSucceeds = true;
|
||||
public readonly List<string> Toasts = new();
|
||||
public readonly List<string> SystemMessages = new();
|
||||
public readonly List<(string Text, RetailLogTextType Type)> InterfaceTexts = new();
|
||||
public readonly List<CombatMode> CombatModeRequests = new();
|
||||
public readonly CombatState Combat = new();
|
||||
public readonly StackSplitQuantityState SplitQuantity = new();
|
||||
|
|
@ -127,7 +129,8 @@ public sealed class ItemInteractionControllerTests
|
|||
return false;
|
||||
Sells.Add((vendorGuid, items));
|
||||
return true;
|
||||
});
|
||||
},
|
||||
interfaceText: (text, type) => InterfaceTexts.Add((text, type)));
|
||||
}
|
||||
|
||||
public ItemInteractionController Controller { get; }
|
||||
|
|
@ -1413,6 +1416,39 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-08-13 social gate round 2, item 5: a refused drop (0x00A0,
|
||||
/// error 0x426 AttunedItem) composes ServerSaysAttemptFailed's
|
||||
/// "The <item> can't be dropped" and routes it as ClientLocal —
|
||||
/// the SpewBox's yellow top-center line. 0x426 has no HandleFailureEvent
|
||||
/// row, so exactly ONE line appears; a failure with no latched request
|
||||
/// shows nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RefusedDrop_ComposesCantBeDroppedLine_AsClientLocal()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A07u;
|
||||
h.AddContained(item);
|
||||
Assert.True(h.Controller.DropToWorld(new ItemDragPayload(
|
||||
item,
|
||||
ItemDragSource.Inventory,
|
||||
SourceSlot: 0,
|
||||
SourceCell: new UiItemSlot())));
|
||||
|
||||
h.Objects.RejectMove(item, 0x426u);
|
||||
|
||||
(string text, RetailLogTextType type) = Assert.Single(h.InterfaceTexts);
|
||||
Assert.Equal($"The Item {item:X} can't be dropped", text);
|
||||
Assert.Equal(RetailLogTextType.ClientLocal, type);
|
||||
|
||||
// No latched request → retail shows nothing (and 0x426 stays out of
|
||||
// the generic failure table).
|
||||
h.InterfaceTexts.Clear();
|
||||
h.Objects.RejectMove(item, 0x426u);
|
||||
Assert.Empty(h.InterfaceTexts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryDragOnNpc_sendsGiveWithoutOptimisticInventoryMutation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Lib;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance for <see cref="DatStringResolver.ResolveTemplate"/> — the
|
||||
/// <c>StringTable::GetString @ 0x004300D0</c> fragment/variable interleave
|
||||
/// (no-metalanguage branch @ 0x004303B7) that composes the social
|
||||
/// confirmation sentences.
|
||||
/// </summary>
|
||||
public sealed class DatStringResolverTemplateTests
|
||||
{
|
||||
private const uint TableId = 0x23000001u;
|
||||
|
||||
[Fact]
|
||||
public void PlayerVariableIsTheRetailHash()
|
||||
=> Assert.Equal(0x05506DA2u, DatStringResolver.PlayerVariable);
|
||||
|
||||
[Fact]
|
||||
public void ComposesTrailingFragmentTemplate()
|
||||
{
|
||||
// ID_Allegiance_SwearConfirmation's shape:
|
||||
// ["Do you wish to swear to ", "?"] + [PLAYER]
|
||||
var resolver = MakeResolver(
|
||||
"ID_Allegiance_SwearConfirmation",
|
||||
fragments: ["Do you wish to swear to ", "?"],
|
||||
variables: [DatStringResolver.PlayerVariable]);
|
||||
|
||||
Assert.Equal(
|
||||
"Do you wish to swear to +Horan?",
|
||||
resolver.ResolveTemplate(
|
||||
TableId,
|
||||
"ID_Allegiance_SwearConfirmation",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = "+Horan",
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposesLeadingVariableTemplate()
|
||||
{
|
||||
// ID_Fellowship_FellowshipRequest's shape: an EMPTY first fragment,
|
||||
// so the player name leads the sentence.
|
||||
var resolver = MakeResolver(
|
||||
"ID_Fellowship_FellowshipRequest",
|
||||
fragments: [
|
||||
"",
|
||||
" has invited you to join their fellowship. Do you accept?",
|
||||
],
|
||||
variables: [DatStringResolver.PlayerVariable]);
|
||||
|
||||
Assert.Equal(
|
||||
"+Acdream has invited you to join their fellowship. Do you accept?",
|
||||
resolver.ResolveTemplate(
|
||||
TableId,
|
||||
"ID_Fellowship_FellowshipRequest",
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[DatStringResolver.PlayerVariable] = "+Acdream",
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingVariableSubstitutesEmpty()
|
||||
{
|
||||
var resolver = MakeResolver(
|
||||
"ID_Allegiance_SwearConfirmation",
|
||||
fragments: ["Do you wish to swear to ", "?"],
|
||||
variables: [DatStringResolver.PlayerVariable]);
|
||||
|
||||
Assert.Equal(
|
||||
"Do you wish to swear to ?",
|
||||
resolver.ResolveTemplate(
|
||||
TableId,
|
||||
"ID_Allegiance_SwearConfirmation",
|
||||
new Dictionary<uint, string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownKeyResolvesNull()
|
||||
{
|
||||
var resolver = MakeResolver(
|
||||
"ID_Allegiance_SwearConfirmation",
|
||||
fragments: ["Do you wish to swear to ", "?"],
|
||||
variables: [DatStringResolver.PlayerVariable]);
|
||||
|
||||
Assert.Null(resolver.ResolveTemplate(
|
||||
TableId, "ID_Not_A_Key", new Dictionary<uint, string>()));
|
||||
}
|
||||
|
||||
private static DatStringResolver MakeResolver(
|
||||
string key,
|
||||
string[] fragments,
|
||||
uint[] variables)
|
||||
{
|
||||
var entry = new StringTableString();
|
||||
foreach (string fragment in fragments)
|
||||
entry.Strings.Add(fragment);
|
||||
foreach (uint variable in variables)
|
||||
entry.Variables.Add(variable);
|
||||
|
||||
var table = new StringTable { Id = TableId };
|
||||
table.Strings[DatStringResolver.ComputeHash(key)] = entry;
|
||||
return new DatStringResolver(new SingleTableSource(table));
|
||||
}
|
||||
|
||||
/// <summary>Serves exactly one constructed StringTable through the
|
||||
/// production <see cref="IDatReaderWriter"/> seam.</summary>
|
||||
private sealed class SingleTableSource : IDatReaderWriter
|
||||
{
|
||||
private readonly StringTable _table;
|
||||
|
||||
public SingleTableSource(StringTable table) => _table = table;
|
||||
|
||||
public string SourceDirectory => string.Empty;
|
||||
public IDatDatabase Portal => throw new NotSupportedException();
|
||||
public IDatDatabase Cell => throw new NotSupportedException();
|
||||
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; } =
|
||||
new(new Dictionary<uint, IDatDatabase>());
|
||||
public IDatDatabase HighRes => throw new NotSupportedException();
|
||||
public IDatDatabase Language => throw new NotSupportedException();
|
||||
public IDatDatabase Local => throw new NotSupportedException();
|
||||
public ReadOnlyDictionary<uint, uint> RegionFileMap { get; } =
|
||||
new(new Dictionary<uint, uint>());
|
||||
public int PortalIteration => 0;
|
||||
public int CellIteration => 0;
|
||||
public int HighResIteration => 0;
|
||||
public int LanguageIteration => 0;
|
||||
|
||||
public bool TryGetFileBytes(
|
||||
uint regionId,
|
||||
uint fileId,
|
||||
ref byte[] bytes,
|
||||
out int bytesRead)
|
||||
{
|
||||
bytesRead = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||
Array.Empty<uint>();
|
||||
|
||||
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) =>
|
||||
Array.Empty<IDatReaderWriter.IdResolution>();
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public bool TrySave<T>(
|
||||
uint regionId,
|
||||
T obj,
|
||||
int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
[return: MaybeNull]
|
||||
public T Get<T>(uint fileId) where T : IDBObj =>
|
||||
fileId == _table.Id && _table is T match ? match : default;
|
||||
|
||||
public bool TryGet<T>(
|
||||
uint fileId,
|
||||
[MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
if (fileId == _table.Id && _table is T match)
|
||||
{
|
||||
value = match;
|
||||
return true;
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Core.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance rows for the ServerSaysAttemptFailed @ 0x0058EAE0 port —
|
||||
/// verb table, suffix map, and the 0x00A0 dispatcher exclusion set
|
||||
/// (@ 0x0055B342).
|
||||
/// </summary>
|
||||
public sealed class InventoryFailureMessagesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(
|
||||
InventoryRequestKind.DropToWorld, "Bloodstone Chunk", 0x426u,
|
||||
"The Bloodstone Chunk can't be dropped")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Give, "Sword", 0u,
|
||||
"The Sword can't be given")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Pickup, "Sword", 0x2Au,
|
||||
"The Sword can't be picked up - you are too encumbered")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.PutInContainer, "Sword", 0x3EEu,
|
||||
"The Sword can't be put in the container - the container is closed")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.Merge, "Arrows", 0x1Du,
|
||||
"The Arrows can't be merged - you're too busy")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.SplitToWorld, "Arrows", 0x38u,
|
||||
"The Arrows can't be split - unable to move to object")]
|
||||
[InlineData(
|
||||
InventoryRequestKind.SplitToContainer, "Arrows", 0x36u,
|
||||
"The Arrows can't be split - action cancelled")]
|
||||
public void ComposeMatchesServerSaysAttemptFailed(
|
||||
InventoryRequestKind kind,
|
||||
string name,
|
||||
uint error,
|
||||
string expected)
|
||||
=> Assert.Equal(expected, InventoryFailureMessages.Compose(kind, name, error));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1Eu)]
|
||||
[InlineData(0x2Bu)]
|
||||
[InlineData(0x3EFu)]
|
||||
[InlineData(0x43Eu)]
|
||||
[InlineData(0x4CEu)]
|
||||
[InlineData(0x4CFu)]
|
||||
[InlineData(0x46Au)]
|
||||
public void ExclusionSetSuppressesGenericFailureText(uint error)
|
||||
=> Assert.True(InventoryFailureMessages.SuppressesGenericFailureText(error));
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x426u)]
|
||||
[InlineData(0x1Du)]
|
||||
[InlineData(0u)]
|
||||
public void OtherErrorsDoNotSuppressGenericFailureText(uint error)
|
||||
=> Assert.False(InventoryFailureMessages.SuppressesGenericFailureText(error));
|
||||
}
|
||||
|
|
@ -240,6 +240,50 @@ public sealed class InventoryTransactionStateTests
|
|||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectMoveFiresRequestFailedWithLatchedKindAndWireError()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
var failures = new List<(PendingInventoryRequest Request, uint Error)>();
|
||||
state.RequestFailed += (request, error) => failures.Add((request, error));
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.DropToWorld, First, static () => true));
|
||||
objects.RejectMove(First, 0x426u);
|
||||
|
||||
(PendingInventoryRequest failed, uint error) = Assert.Single(failures);
|
||||
Assert.Equal(InventoryRequestKind.DropToWorld, failed.Kind);
|
||||
Assert.Equal(First, failed.ItemId);
|
||||
Assert.Equal(0x426u, error);
|
||||
Assert.False(state.HasPendingRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequestFailedRequiresTheLatchedGuidAndAnActivePending()
|
||||
{
|
||||
var objects = CreateTable();
|
||||
using var state = new InventoryTransactionState(objects);
|
||||
int failures = 0;
|
||||
state.RequestFailed += (_, _) => failures++;
|
||||
|
||||
Assert.True(state.TryDispatch(
|
||||
InventoryRequestKind.DropToWorld, First, static () => true));
|
||||
|
||||
// A failure for a DIFFERENT item must not consume (or mislabel) the
|
||||
// latch — the stale-latch guard on retail's latched-guid preference.
|
||||
objects.RejectMove(Second, 0x426u);
|
||||
Assert.Equal(0, failures);
|
||||
Assert.True(state.HasPendingRequest);
|
||||
|
||||
objects.RejectMove(First, 0x426u);
|
||||
Assert.Equal(1, failures);
|
||||
|
||||
// RecordResponse cleared the latch; a repeat failure shows nothing.
|
||||
objects.RejectMove(First, 0x1Du);
|
||||
Assert.Equal(1, failures);
|
||||
}
|
||||
|
||||
private static ClientObjectTable CreateTable()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue