diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 80348f0b..e157a736 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 71 active rows (AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 72 active rows (AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -176,17 +176,18 @@ readiness/requeue adaptation. See | AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (21 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | | AD-79 | **MOSTLY RETIRED 2026-08-13 (user-ordered social completion batch):** Friends Add/Remove/Appear-Offline and Squelch add-character/add-account/remove are LIVE (the wire beneath had existed end-to-end since J4.1/FA1 — docs/research/2026-08-13-social-wire-completion.md §4; the panel now publishes the same Runtime commands). REMAINING scope: the Friends "Send Tell" button (`0x10000516`) only, which needs the chat-tell seam. **Original filing — 2026-08-12 at Campaign FA slice FA3, D1 (the plan's "Friends + Squelch pages bind READ-ONLY... their mutation actions are wired only if their wire is already served by ACE and trivially pinnable in-slice — otherwise the action buttons are honest INERT" decision).** The social panel's Friends page authors three buttons (Add/Remove Friend-shaped, `0x10000514`/`0x10000515`/`0x10000516`) plus an "Appear Offline"-shaped checkbox (`0x1000052C`); the Squelch page authors three buttons (`0x10000547`/`0x1000054B`/`0x1000054C`). All seven are built, laid out, and clickable exactly as authored, but carry no click handler — no Friends add/remove/appear-offline wire and no Squelch add/remove/clear wire is implemented this campaign. `gmFriendsUI`/`gmSquelchUI` were also outside lane A/B/C/D's own decompiled scope (only Fellowship/Allegiance were researched), so their real button semantics and wire opcodes are not yet established either — this row covers BOTH "not wired" and "not yet researched." | `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs`; `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs` (both classes' own doc comments cite this row) | FA3 is the panel SHELL slice; D1 sets the bar for which Friends/Squelch actions get wired in-slice at "trivially pinnable," which none of these seven meet without their own wire research. `SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler` pins the INERT contract so a future consumer landing without also removing this row's citation fails nothing silently — the row is the only signal until a follow-up slice wires real handlers. | A user clicking Add/Remove Friend, Appear Offline, or any Squelch button in acdream sees no effect and no feedback — indistinguishable from a dead control unless they already expect the gap. The Friends/Squelch LISTS themselves are live (bound read-only to `RuntimeCommunicationState.Friends`/`.Squelch`) — only the mutation controls are inert. | None (no retail decomp anchor — `gmFriendsUI`/`gmSquelchUI` are outside this campaign's researched scope); `docs/research/2026-08-11-fa-panel-structure.md` §10 (coordinator addendum, the panel discovery that first surfaced these two pages); `docs/plans/2026-08-11-fellowship-allegiance-campaign.md` D1 | | AD-80 | **Filed 2026-08-12 at Campaign FA slice FA4, D5.** The fellowship page's per-fellow percentage text renders retail's own byte-decoded XP-share table verbatim (1.0/.75/.6/.55/.5/.45/.4/.35/.3111111/.28, default 0.0 — `docs/research/2026-08-11-fa-fellowship-wire.md` §7.2, byte-decoded from the PDB-paired binary because both available decompilers folded the function to a constant). The currently-targeted ACE server computes the ACTUAL distributed XP from a DIFFERENT table (`.3` at 9 fellows instead of `.3111111`, no explicit 10-fellow row, and a wrong out-of-range default of `1.0` instead of `0.0` — `Fellowship.cs:604-632`, lane B §4.3). So a full (9-member) or over-full-in-retail's-table (10-member) fellowship's displayed percentage will not exactly match the XP ACE actually grants. This is a divergence between ACE and RETAIL, not between acdream and retail — acdream's client-side display is retail-faithful — but it is filed here because it is directly user-visible through this panel and a tester comparing "panel says 31.1%" against "server granted 30%" is measuring ACE's bug, not acdream's port. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`EvenSplitPercentTable`, `FormatStatsText`) | The client-side table is byte-verified against the retail binary; re-deriving it to match ACE's (wrong) numbers would make acdream disagree with a REAL retail client observing the same fellowship, which is the opposite of this project's goal. | A tester with a 9- or 10-member fellowship on ACE sees a panel percentage that does not exactly match the XP bonus they actually receive; below 9 members the two agree exactly. The proportional (non-even-split) branch has a SEPARATE, narrower gap: acdream has not ported an `ExperienceToRaiseLevel`-equivalent table, so that branch omits the percentage entirely (level only) rather than computing a wrong number — see AD-81's citation of the same method. | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (lane B §7.2); ACE `Fellowship.cs:604-632`; `docs/research/2026-08-11-fa-fellowship-wire.md` §4.3 | -| AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) | +| AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. **AMENDED 2026-08-13:** the no-metalanguage fragment/variable interleave of `StringTable::GetString @0x004300D0` IS now ported as `DatStringResolver.ResolveTemplate` (the AD-85 dialog narrowing), so VERIFIED-token-free templates can resolve exactly; this row's remaining scope is the meta-token engine (`StringTableMetaLanguage::RenderString @0x004302B1` + `StripMetaLetters`) the multi-variable stats templates may need, plus `FormatName`. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) | | AD-82 | **NARROWED 2026-08-13 (user-directed):** the invented leader-gold and selection-blue name tints are DELETED — fellow names render white always, selection feedback is the in-game selection ring, and the row-click target widened to the name AND stats texts. Remaining scope below. **Original filing — 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5).** Three fellowship-panel selection/presentation primitives with no decompiled anchor for the SPECIFIC mechanism, plus a deliberately page-local reimplementation of a retail generic: (1) **the leader-name gold tint** (`SocialFellowshipPageController.LeaderNameColor`, `(1, 0.84, 0, 1)`) — lane A's row-template inventory names no dedicated "this fellow is the leader" element, so this is an invented, clearly-adaptive visual cue, not a ported DAT mechanism. (2) **The panel-local "selected row" tint** (`SelectedNameColor`, `(0.45, 0.85, 1, 1)`) — same disposition, invented for the SAME reason: no decompiled per-row selection marker exists. (3) **Row selection is restricted to the row's name-text click target** — retail's list selection message (`3`/`0x42`, `ListenToElementMessage @0x004901C0`) fires on the WHOLE row; acdream has no generic per-row-element click primitive on an imported template subtree, so only the name text (always present) is clickable — clicking the stats text, a meter, or row whitespace does nothing. (4) **The world→panel selection sync is page-local, not a generic `UiTemplateListBox` primitive** — retail's `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0` keys row identity via `SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` + `UIElement_ListBox::SetSelectedItem`, a mechanism `UiTemplateListBox` does not port (`docs/research/2026-08-11-fa-panel-structure.md` §6.6: "no Flush, no selection model, no per-row instance-id" — `Flush`/`FlushPreservingScroll` shipped at FA3/FA4; the selection half did not). `SocialFellowshipPageController.SyncSelectionFromWorld`/`SetSelectedFellow` reproduce the OBSERVABLE behavior (Dismiss/Leader enable + a row highlight) against this controller's own guid-keyed row dictionary instead. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`LeaderNameColor`, `SelectedNameColor`, `SelectFellow`, `SyncSelectionFromWorld`, `SetSelectedFellow`, `_rows`) | (1)/(2): a minimal, clearly-adaptive visual cue is preferable to inventing a DAT mechanism that was never found — same reasoning the class doc already applied to the leader tint before this row existed. (3): acdream's widget layer has no generic "whole imported subtree is one click target" primitive; the name text is retail's own always-present anchor. (4): the OBSERVABLE contract (button-enable + highlight on world selection) is met without porting the generic `UiTemplateListBox`/`SetAttribute_InstanceID` selection model, which would need a broader ListBox API change touching every ListBox consumer (Options/Config/Chat/Friends/Squelch), not just Fellowship — scoped here as a deliberate, page-local minimum rather than an unscoped widget-layer redesign. | A reviewer comparing a retail screenshot sees two colors retail never paints (gold leader tint, blue selection tint). A user clicking a row's stats text, a meter, or blank row space gets no selection feedback (must click the name specifically). If a future slice (Options/Config/Chat row selection) needs the SAME generic mechanism, this page-local implementation will not serve it — a real `UiTemplateListBox` selection-model port remains owed. | `gmFellowshipUI::UpdateFellowSelection @0x0048F0F0`; `RecvNotice_SelectionChanged @0x0048F1C0`; `ListenToElementMessage @0x004901C0` (message `3`/`0x42`); `docs/research/2026-08-11-fa-panel-structure.md` §6.2/§6.6/§7.3 | **[FA5 addendum, 2026-08-12:** `SocialAllegiancePageController`'s vassal-row click target shares point (3)'s IDENTICAL limitation — only the row's name text (`0x10000268`) is clickable, for the same "no generic per-row click primitive" reason. UNLIKE Fellowship's row click, Allegiance's does NOT sync to the world selection (lane A §6.2: `gmAllegianceUI::ListenToElementMessage`'s list-selection arm reads the row's `0x10000001` into `m_iidSelectedVassal` only — no `ACCWeenieObject::SetSelectedObject` call), so point (4)'s world→panel sync does not apply to Allegiance at all; only points (1)-(3)'s class of limitation recurs, and point (1)/(2)'s invented tint colors are NOT reused. **[FA5 mechanism-review SF-1, 2026-08-12: the interim offline-grey (`OfflineNameColor`) this addendum first cited was ITSELF an invented visual — retail's `UpdateVassalsData @004924c3` writes the vassal name with no colour change; the offline cue is EXCLUSIVELY the authored `0x100004AA` marker (`SetVisible` per online state, already wired). `OfflineNameColor` is removed; the vassal name always renders in the normal white, pinned by `SocialPanelControllerTests.Allegiance_OfflineCue_IsTheMarkerOnly_NameStaysWhite`. The Allegiance page now carries NO invented tint at all.]**]** | | AD-83 | **Filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5).** The Recruit button's enable rule does not gate on "target is a player" — retail disables Recruit unless the currently-selected world object IS a player (`ACCWeenieObject::IsPlayer`, `UpdateButtons`, lane B §2.8); acdream's UI layer has no cheap player-vs-non-player classification at this seam, so `RefreshButtonStates` enables Recruit for ANY selected, non-full-fellowship, not-already-a-member target regardless of type. This was previously an inline code comment, not a register row — the wrong call under the register rule (a divergence found without a row is a bug twice over), corrected here. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`RefreshButtonStates`) | acdream's `SelectionState`/world-object model does not carry a player-vs-non-player classification cheaply reachable from the UI layer today; building one for this single enable-rule would be a disproportionate addition for a superset-of-retail rule whose actual SEND is still refused correctly. | A lit, clickable Recruit button when a chest, corpse, or monster is selected instead of a player — clicking it sends a Recruit request the SERVER refuses (the same silent no-op retail's own disabled button would have produced, but reachable in acdream where retail's click handler is unreachable because the button itself is disabled). Not a wire-behavior gap — the recruited/target end state is identical — but a UI-affordance divergence a screenshot comparison would catch. | `gmFellowshipUI::UpdateButtons` (lane B §2.8, the Recruit enable rule); `ACCWeenieObject::IsPlayer` (unlocated exact VA — cited via lane B's UpdateButtons trace) | | AD-84 | **Filed 2026-08-12 at Campaign FA slice FA5.** The Allegiance page's Swear button enable rule does not gate on "target is a player" — retail's `gmAllegianceUI::UpdateSwearButton @0x004908E0` enables Swear only when the current world selection `ACCWeenieObject::IsPlayer()` (lane C §1.3 step 1); acdream's UI layer has the same missing player-vs-non-player classification AD-83 already named for the Fellowship page's Recruit button, so `RefreshButtonStates` enables Swear for any selected, not-already-a-member, not-self target regardless of type. Same root cause and same disposition as AD-83, filed separately because it lives in a different controller/page. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshButtonStates`) | Identical to AD-83's argument: acdream's `SelectionState`/world-object model has no cheap player classification at this UI seam; building one for two single enable-rules (Recruit, Swear) is a disproportionate addition, and the server still refuses a non-player Swear target the same way retail's own disabled button would have silently no-op'd. | A lit, clickable Swear button when a non-player object is selected — clicking it sends a Swear request the SERVER refuses. Not a wire-behavior gap (the swear/target end state is identical to retail's disabled-button no-op) — a UI-affordance divergence a screenshot comparison would catch. | `gmAllegianceUI::UpdateSwearButton @0x004908E0` (lane C §1.3 step 1); `ACCWeenieObject::IsPlayer` (unlocated exact VA, same as AD-83) | -| AD-85 | **Filed 2026-08-12 at Campaign FA slice FA5.** The SAME unported `StringInfo` variable-substitution engine AD-81 filed for the Fellowship page's row/stats text (`StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`) also affects the Allegiance page's numeric fields and its three LOCAL confirmation dialogs: (1) **Numeric fields** — self/monarch followers (`0x10000252`/`0x10000258`), self rank (`0x10000253`), and the "experience passed up" text (`0x10000492` ×2, the vassal row's `0x10000269`) render as bare numbers with no surrounding retail words, same disposition as AD-81's `"{level} {pct}%"`. (2) **Swear/Break/Kick confirmation dialogs** — `MakeSwearConfirmationDialog`/`MakeBreakConfirmationDialog`/`MakeKickConfirmationDialog` bind the target's name into a `StringInfo` template (`ID_Allegiance_SwearConfirmation`/`BreakConfirmation`/`KickConfirmation`, lane C §5.1); this controller shows the RESOLVED retail template text VERBATIM (unsubstituted — the raw DAT string, whatever it says) when resolution succeeds, and falls back to the bare target name (also non-invented) when it does not. Neither path ever composes new English. (3) **The server-driven "accept incoming swear" dialog** (`ConfirmationType` 1, handled generically by `GameplayConfirmationController` — no allegiance-specific code) inherits the SAME gap from the wire side: ACE sends the target's bare `Name` as the ENTIRE confirmation message (lane C §6.4: `Player_Allegiance.cs:91`/`ConfirmationManager.cs:38`), not a formatted sentence — retail's own client wraps it via the identical `StringInfo` mechanism this row already covers. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshSelfBlock`, `RefreshMonarchBlock`, `RefreshPatronBlock`, `UpdateRow`, `OnSwearClick`, `OnBreakClick`, `OnKickClick`) | Same argument as AD-81: porting `StringTableMetaLanguage` is a cross-cutting prerequisite, not allegiance-specific, and guessing its token/placeholder syntax to blend a name into an unverified sentence shape would risk silently-wrong substitution — exactly the guessing CLAUDE.md's workflow forbids. Using retail's own unsubstituted text verbatim (or the bare name) is the non-inventing alternative. | A user sees bare numbers instead of retail's full sentences for followers/rank/XP-passed-up, and the three local confirmation dialogs (plus the server-driven accept-swear dialog) show either an unsubstituted retail template or a bare name instead of "Swear allegiance to PlayerName?"-style prose. The underlying DATA (the numbers, the target's real name) is correct in every case — only the surrounding words are absent. | `gmAllegianceUI::UpdatePlayerData @0x00491330`, `UpdateMonarchData @0x00491B40`, `UpdatePatronData @0x004917C0`, `UpdateVassalsData @0x00492340` (lane C/A field sources); `MakeSwearConfirmationDialog @0x004927B0` family (lane A §5.1); `StringInfo::InqString @0x0042e490` (unresolved, same citation as AD-81) | +| AD-85 | **Filed 2026-08-12 at Campaign FA slice FA5. NARROWED 2026-08-13 (social gate round 2):** the row's items 2 and 3 — the three LOCAL Swear/Break/Kick confirmation dialogs and the server-driven type-1 accept-swear dialog (plus the type-4 fellowship invite) — are PORTED: `DatStringResolver.ResolveTemplate` composes the exact `0x23000001` templates (`ID_Allegiance_SwearConfirmation`/`BreakConfirmation`/`KickConfirmation`, `ID_Allegiance_AcceptSwearConfirmation`, `ID_Fellowship_FellowshipRequest`) by the `StringTable::GetString @0x004300D0` fragment/PLAYER-variable interleave (no-metalanguage branch `@0x004303B7`; all five templates verified token-free — `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §1.2). What REMAINS recorded: item 1 — the numeric fields: self/monarch followers (`0x10000252`/`0x10000258`) and self rank (`0x10000253`) now carry `Followers:`/`Rank: [n]` label text but not retail's `StringInfo`-resolved sentence, and the "experience passed up" text (`0x10000492` ×2, the vassal row's `0x10000269`) renders bare numbers, same disposition as AD-81's `"{level} {pct}%"`. Those templates are multi-variable and were not verified token-free; they can move onto `ResolveTemplate` after the same verification. | `src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs` (`RefreshSelfBlock`, `RefreshMonarchBlock`, `RefreshPatronBlock`, `UpdateRow`) | Same argument as AD-81 for the remainder: the numeric-field templates have not been dumped/verified token-free, and guessing meta-token behavior would risk silently-wrong substitution. The dialog templates WERE verified, which is why they moved. | A user sees bare numbers instead of retail's full sentences for followers/rank/XP-passed-up. The confirmation dialogs now read retail's full sentences ("Do you wish to swear to X?", "X would like to swear allegiance to you. Do you accept?"). | `gmAllegianceUI::UpdatePlayerData @0x00491330`, `UpdateMonarchData @0x00491B40`, `UpdatePatronData @0x004917C0`, `UpdateVassalsData @0x00492340` (lane C/A field sources); `MakeSwearConfirmationDialog @0x004927B0` family (lane A §5.1); `StringTable::GetString @0x004300D0` (ported for token-free templates); `StringTableMetaLanguage::RenderString @0x004302B1` (still unported — AD-81) | | AD-86 | **Filed 2026-08-12 at Campaign FA slice FA5, item 4.** ACE deliberately zeroes or empties NINE `AllegianceProfile`/`AllegianceData` fields on the wire — officers, officer titles, MOTD, MOTD-set-by, name-last-set-time, lock state, and approved vassal are always empty/false/zero regardless of the allegiance's real state; `timeOnline`/`allegianceAge` (the remaining two) are hard-coded 0 forever (lane C §5.1). acdream's FA1 parser reads all of these (to keep the byte cursor aligned for the fields after them) but drops most at increasing layers: `AllegianceMemberRecord` never surfaces `timeOnline`/`allegianceAge` as fields at all; `RuntimeAllegianceState.ApplyUpdate` (FA2) does not forward `Motd`/`MotdSetBy`/`ChatRoomId`/`NameLastSetTime`/`IsLocked`/`ApprovedVassal` from the parsed `AllegianceUpdate` record to `RuntimeAllegianceSnapshot` even though the C# record itself carries them; retail's own `gmAllegianceUI` (FA5) has no widget for any of the seven either (lane A §3.3: "No allegiance MOTD / officer / ban / hometown UI" — they are chat-verb-only in the 2013 client, out of this campaign's scope per the plan's §4). | `src/AcDream.Core.Net/Messages/ClientCommandResponses.cs` (`ReadAllegianceProfileBody`, `AllegianceMemberRecord`, `AllegianceUpdate`); `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyUpdate`) | Retail's own client renders nothing for these seven fields either (no panel widget consumes them) — dropping them past the parse layer matches retail's OWN presentation exactly, and is strictly safer than surfacing values that are always wrong/empty against ACE. | Any FUTURE consumer (the chat-verb-only officer/MOTD/lock/ban management features, §2 master table features #11-31 of the allegiance wire research, explicitly out of Campaign FA's scope) that reads these fields off the Runtime layer will find them permanently zero/empty against ACE regardless of the allegiance's real server-side state — do not chase this as a parser bug; it is ACE's own zeroing. | ACE `Network/Structure/AllegianceHierarchy.cs:53-56,62-64,74-75,78-83,86-89,153-155` (broadcast counters/isLocked/officers/officerTitles/motd/approvedVassal); ACE `Network/Structure/AllegianceData.cs:59-60,86-89,111-112` (timeOnline/allegianceAge); `docs/research/2026-08-11-fa-allegiance-wire.md` §5.1 | | AD-87 | **Filed 2026-08-12 at Campaign FA slice FA6.** The allegiance-swear half of the two-bot headless connected gate (`FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) is written and wired end-to-end (proximity, `0x001D` swear, the confirmation-relay seam, `0x0020` tree-reseed assertions, break, reconnect-idempotence) but has never actually been verified to complete over the wire — `AllegianceGateEnabled = false` in both classes keeps it unreachable by default. Six live runs against local ACE all reproduced the same result: the fellowship half passes decisively (the Recruit bot's own `RuntimeFellowshipState` flips, proven three separate times), but ACE returns nothing at all to the `0x001D` swear (no `0x0274` confirmation, no `0x0020`, no error) even at 0.005 m separation — see docs/ISSUES.md #384 for the full evidence trail. So while the FELLOWSHIP two-session machinery is proven live, the ALLEGIANCE two-session machinery (Runtime commands, wire builders, `RuntimeAllegianceState` reseed) remains unverified end-to-end over a real connection — only its unit/fixture-level tests and its (successful) LOCAL echo on the swearer's own client are exercised. | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` (`FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled`, `FellowshipAllegianceRecruitBotPolicy.AllegianceGateEnabled`, both `false`) | Shipping the fellowship gate ALONE (rather than blocking the whole slice on the allegiance blocker) matches the campaign's own D8/item-6 split — fellowship and allegiance are independent retail systems with independent wire families, and the fellowship half's proof stands on its own regardless of the allegiance outcome. Disabling rather than deleting the allegiance code keeps a reviewed-quality, ready-to-run harness in place for whoever closes #384. | Anyone reading "the FA6 bot-vs-ACE gate passed" without the qualifier could assume the allegiance swear/break/reconnect path is proven over the wire when it is not — only its LOCAL send-and-echo behavior is proven; ACE's actual acceptance of the swear is the open question #384 tracks. | docs/ISSUES.md #384; `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 (the expected `0x0274`/`0x0275`/`0x0020` handshake); run6 evidence (0.005 m distance, zero inbound after swear) | | AD-88 | **Filed 2026-08-13 at the #385 dropdown fix (classification: UNCLEAR).** The vendor category dropdown ships G5's fixed 6-row scrollable popup window, but its authored popup ListBox (`0x21000043/0x10000350`) is edge-docked on all four sides (L=T=R=B=1, measured by menuprobe3 `OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`) — the exact authored condition that arms retail `UIElement_Menu::RecalculatePopupSize @0x0046caf0`, which resizes the popup to the ListBox's summed content height, uncapped (`0x0046e5f4..0046e66c`). The Config option-menus' identical docked shape now drives `UiMenu.PopupSizeToContent=true` (#385); vendor deliberately keeps `false`. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (its UiMenu wiring leaves `PopupSizeToContent` at the class-default false) | The G5 vendor-gate retail screenshot was read as a ~6-row-with-scrollbar look and the vendor connected gate USER-PASSED on that shape — reworking a user-gated surface on decomp inference alone would invert the retail-oracle rule. The two pieces of evidence conflict; the row records the conflict rather than silently picking a side. | If retail actually opens the category popup full-height, our vendor dropdown shows a 6-row scroll window where retail shows every category at once — visible at any vendor with >6 categories. If retail truly shows 6 rows, the mechanism question (why the docked ListBox does not trigger RecalculatePopupSize there) is unanswered and could mislead the next dropdown port. | docs/ISSUES.md #386 (the retail side-by-side to run + the two candidate resolutions); #385 (the Config fix that exposed the conflict) | | AD-90 | **Filed 2026-08-13 at the #389 mechanism-review fix round (finding M1).** Retail's smartbox divisor aspect is not raw width/height: `RenderDevice::ComputeAspectForViewport @0x0054f150` yields `(w/h) × m_DisplayAspectRatio × 0.75`, with `m_DisplayAspectRatio` fed by the registered `Render.AspectRatio` preference. At that preference's DEFAULT (4:3) the factor is exactly 1.0f and the expression collapses to raw w/h — which is what acdream uses. acdream carries no AspectRatio preference at all. Also folded in: retail's `SetFOVRad` gate arithmetic ACCEPTS NaN (x87 unordered-compare quirk) where acdream's port rejects it — unreachable in practice, deliberately not reproduced (mechanism review M3). | `src/AcDream.App/Rendering/RetailFieldOfView.cs` (class doc names this row) | Bit-exact at retail's registered default; the preference existed for 2003-era stretched-CRT correction with no modern counterpart. Reproducing it would add a user knob retail itself defaulted away. | A retail user who had changed `Render.AspectRatio` saw framing acdream cannot reproduce; anyone porting FOV behavior from a capture made with a non-default AspectRatio preference will measure a mismatch against our law. | `RenderDevice::ComputeAspectForViewport @0x0054f150`; `Render::SetFOVRad @0x0054b2d0`; consumer `D3DXMatrixPerspectiveFovLH @0x0059ab71`; docs/research/2026-08-13-389-fov-mechanism-review.md | | AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | +| AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | --- diff --git a/docs/research/2026-08-13-confirm-and-weenie-error-display.md b/docs/research/2026-08-13-confirm-and-weenie-error-display.md new file mode 100644 index 00000000..6fffbd63 --- /dev/null +++ b/docs/research/2026-08-13-confirm-and-weenie-error-display.md @@ -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:` `@
`); 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(, 0x10000001); // table enum → StringTable DID +si.AddVariable_String(, 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 `` and `` 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): **` has invited you to join their fellowship. Do you accept?`** +- Incoming swear (patron sees): **` would like to swear allegiance to you. Do you accept?`** +- Local swear (vassal clicks Swear): **`Do you wish to swear to ?`** (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 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` — 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 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 + ` has invited you to join their fellowship. Do you accept?` +- Incoming swear (two-client): patron's dialog reads + ` would like to swear allegiance to you. Do you accept?` +- Local swear click: `Do you wish to swear to ?` (full sentence). +- Drop an attuned item: yellow top-center `The 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. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index b0bc15a7..75eacc76 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -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, diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs index b23686d0..75758734 100644 --- a/src/AcDream.App/UI/GameplayConfirmationController.cs +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -13,6 +13,7 @@ public sealed class GameplayConfirmationController : IDisposable { private readonly RetailDialogFactory _dialogs; private readonly Action _sendResponse; + private readonly Func? _composeMessage; private uint _dialogContext; private uint _serverType; private uint _serverContext; @@ -20,10 +21,12 @@ public sealed class GameplayConfirmationController : IDisposable public GameplayConfirmationController( RetailDialogFactory dialogs, - Action sendResponse) + Action sendResponse, + Func? 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); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index adba7dbf..29555b95 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -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 _dragOnPlayerOpensSecureTrade; private readonly Action? _systemMessage; + private readonly Action? _interfaceText; private readonly AutoWieldController _autoWield; private readonly Action? _requestUse; // Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true @@ -115,7 +117,8 @@ public sealed class ItemInteractionController : IDisposable Action? requestUse = null, Func? sendBuy = null, Func, uint, bool>? sendBuyAll = null, - Func, bool>? sendSell = null) + Func, bool>? sendSell = null, + Action? 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; } + /// + /// ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0: 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. + /// + 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); + } + } + + /// + /// The 0x00A0 dispatcher's second leg (case 0xA0 @ 0x0055B342): + /// unless the error is in the exclusion set, the generic + /// HandleFailureEvent 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. + /// + 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; diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs index 999d4d17..4932c47a 100644 --- a/src/AcDream.App/UI/Layout/DatStringResolver.cs +++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs @@ -61,6 +61,60 @@ public sealed class DatStringResolver : null; } + /// Retail's variable-name hash for PLAYER (0x05506DA2) — + /// the single substitution slot every social confirmation template uses. + public static readonly uint PlayerVariable = ComputeHash("PLAYER"); + + /// + /// Composes a templated StringTable entry: N (or N+1) literal fragments + /// interleaved with N named variables, keyed by the entry-key hash. + /// + /// + /// Exact port of StringTable::GetString @ 0x004300D0's + /// no-metalanguage branch @ 0x004303B7: each fragment is appended, + /// then the variable in the same slot (resolved through + /// , keyed by of the + /// authored variable name; a missing variable substitutes the empty + /// string, as retail does). This is NOT a + /// StringTableMetaLanguage::RenderString port — callers own + /// keeping it to token-free templates (register row AD-81's scope note). + /// + public string? ResolveTemplate( + uint tableId, + string key, + IReadOnlyDictionary variables) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(variables); + if (tableId == 0u) + return null; + + if (!_tables.TryGetValue(tableId, out StringTable? table)) + { + table = _dats.Get(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(); + } + /// /// Exact retail ELF-style string hash used for StringInfo keys. /// Ported line-for-line from compute_str_hash @ 0x00413110. diff --git a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs index 22fd2d99..620c94ba 100644 --- a/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs +++ b/src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs @@ -183,7 +183,12 @@ public sealed class SocialAllegiancePageController Func TemplateResolver, Func ResolveString, Func ResolveWorldObjectName, - Func, uint> ShowConfirmation); + Func, 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? ResolvePlayerTemplate = null); private readonly record struct VassalRowWidgets( UiText? Name, @@ -216,12 +221,13 @@ public sealed class SocialAllegiancePageController /// Bind-time-resolved (never per-tick — same discipline every /// other DatStringResolver consumer in this codebase follows). /// Null when resolution failed — the affected widget then keeps its - /// import-time text/caption rather than showing invented English. + /// 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 + /// (retail's own + /// MakeXxxConfirmationDialog shape — 2026-08-13 AD-85 fix). private readonly string? _monarchLabelCaption; private readonly string? _patronSlashMonarchLabelCaption; - private readonly string? _swearConfirmationTemplate; - private readonly string? _breakConfirmationTemplate; - private readonly string? _kickConfirmationTemplate; private readonly Dictionary _rows = new(); private readonly HashSet _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 ?" — 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); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index a048405f..b48f2263 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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 + { + [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 + { + [DatStringResolver.PlayerVariable] = bareName, + }); + } + } _gameplayConfirmationController = new GameplayConfirmationController( DialogFactory, - _bindings.Confirmations.SendResponse); + _bindings.Confirmations.SendResponse, + ComposeConfirmation); _itemConfirmationController = new RetailItemConfirmationController( DialogFactory, ItemInteraction); diff --git a/src/AcDream.Core/Chat/InventoryFailureMessages.cs b/src/AcDream.Core/Chat/InventoryFailureMessages.cs new file mode 100644 index 00000000..c05c3269 --- /dev/null +++ b/src/AcDream.Core/Chat/InventoryFailureMessages.cs @@ -0,0 +1,81 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Chat; + +/// +/// Composes the client-side "The <item> can't be <verb>" refusal +/// line retail shows when the server rejects an inventory request +/// (InventoryServerSaveFailed 0x00A0). +/// +/// +/// Exact port of ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0: +/// the base text switches on the latched request kind +/// (ACCWeenieObject::prevRequest, enum InventoryRequest, +/// acclient.h:6812-6825), then an error-code suffix is appended, and +/// the result is displayed via ECM_UI::SendNotice_DisplayStringInfo(0x1A) +/// — LogTextType 0x1A (), the +/// SpewBox-only channel. The event dispatcher (case 0xA0 @ 0x0055B342) +/// additionally routes most errors through +/// ClientCommunicationSystem::HandleFailureEvent; the exclusion set it +/// checks first lives in . +/// +public static class InventoryFailureMessages +{ + /// + /// 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). must already be the + /// retail-appropriate display name (plural for merge/split — + /// NAME_PLURAL; appropriate otherwise). + /// + 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)}"; + } + + /// + /// ServerSaysAttemptFailed's error suffix map; every other code — + /// including 0x426 AttunedItem — gets no suffix. + /// + 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", + _ => "", + }; + + /// + /// The 0x00A0 dispatcher's exclusion set (@ 0x0055B342): these + /// errors skip the HandleFailureEvent leg entirely (their text, if + /// any, is owned elsewhere). + /// + public static bool SuppressesGenericFailureText(uint weenieError) + => weenieError is 0x1Eu or 0x2Bu or 0x3EFu or 0x43Eu or 0x4CEu + or 0x4CFu or 0x46Au; +} diff --git a/src/AcDream.Core/Items/InventoryTransactionState.cs b/src/AcDream.Core/Items/InventoryTransactionState.cs index f233bc89..bf6ab57d 100644 --- a/src/AcDream.Core/Items/InventoryTransactionState.cs +++ b/src/AcDream.Core/Items/InventoryTransactionState.cs @@ -77,6 +77,18 @@ public sealed class InventoryTransactionState : IDisposable public event Action? StateChanged; public event Action? RequestCompleted; + + /// + /// 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 + /// ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0 consumes: + /// it reads prevRequest to pick the "can't be <verb>" text + /// before RecordResponse clears the latch. Fires after + /// for the same request. + /// + public event Action? 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(Action? listeners, T1 first, T2 second) + { + if (listeners is null) + return; + foreach (Action listener in listeners.GetInvocationList()) + { + try + { + listener(first, second); + } + catch (Exception error) + { + RecordDispatchFailure(error); + } + } + } + private void RecordDispatchFailure(Exception error) { Interlocked.Increment(ref _dispatchFailureCount); diff --git a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs index c920b911..14205631 100644 --- a/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/GameplayConfirmationControllerTests.cs @@ -41,9 +41,12 @@ public sealed class GameplayConfirmationControllerTests /// client reads neither option bit on the invite path; /// RetailUiRuntime.HandleConfirmationRequest 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 Handle_Character__ConfirmationRequest @0x005640A0'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 + /// Handle_Character__ConfirmationRequest @0x005640A0's case-4 arm. + /// (Production now injects the StringTable template composer — see + /// ; + /// this test remains the null-composer fallback contract.) /// [Fact] public void FellowshipInviteRequest_Type4_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose() @@ -72,6 +75,65 @@ public sealed class GameplayConfirmationControllerTests Assert.Equal(0u, controller.ActiveDialogContext); } + /// + /// 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. + /// + [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(); + 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(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider() + .Select(static line => line.Text))); + Assert.IsType(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(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider() + .Select(static line => line.Text))); + Assert.IsType(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(shown!.FindElement( + RetailConfirmationDialogView.MessageElementId)).LinesProvider() + .Select(static line => line.Text))); + Assert.Equal([4u, 1u], composed); + } + /// /// Campaign FA slice FA5, item 3: verifies the allegiance-swear /// confirmation (ConfirmationType.AllegianceSwear, 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 SocialAllegiancePageController 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: - /// Player_Allegiance.cs:91/ConfirmationManager.cs:38), not - /// a full sentence, which this test's message deliberately mirrors - /// rather than inventing retail's unported StringInfo-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: + /// Player_Allegiance.cs:91/ConfirmationManager.cs:38). + /// (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.) /// [Fact] public void AllegianceSwearRequest_Type1_OpensDialog_MessageVerbatim_AndSendsAcceptOnClose() diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 30ba6acb..a1adc020 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -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 Toasts = new(); public readonly List SystemMessages = new(); + public readonly List<(string Text, RetailLogTextType Type)> InterfaceTexts = new(); public readonly List 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); } + /// + /// 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. + /// + [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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs new file mode 100644 index 00000000..9aefda93 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs @@ -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; + +/// +/// Conformance for — the +/// StringTable::GetString @ 0x004300D0 fragment/variable interleave +/// (no-metalanguage branch @ 0x004303B7) that composes the social +/// confirmation sentences. +/// +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 + { + [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 + { + [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())); + } + + [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())); + } + + 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)); + } + + /// Serves exactly one constructed StringTable through the + /// production seam. + 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 CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => throw new NotSupportedException(); + public IDatDatabase Language => throw new NotSupportedException(); + public IDatDatabase Local => throw new NotSupportedException(); + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + 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 GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave( + uint regionId, + T obj, + int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => + fileId == _table.Id && _table is T match ? match : default; + + public bool TryGet( + 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() { } + } +} diff --git a/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs new file mode 100644 index 00000000..4a24b9be --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Chat; +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Conformance rows for the ServerSaysAttemptFailed @ 0x0058EAE0 port — +/// verb table, suffix map, and the 0x00A0 dispatcher exclusion set +/// (@ 0x0055B342). +/// +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)); +} diff --git a/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs b/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs index 9672e1ab..8d5f9afb 100644 --- a/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/InventoryTransactionStateTests.cs @@ -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();