fix(chat): CH4 re-review fixes — dialog-queue reentrancy, settings option-bit chokepoint

Should-fix 1: RetailDialogFactory.CloseDialog's queued branch removed the
active DialogInfo, ran DialogDone (whose callback can synchronously open a
new dialog under the SAME queue key — the two-stage house-abandon
confirmation does exactly this), then called OpenNextDialog, which did an
unconditional Dictionary.Add on a key the reentrant dialog had already
re-occupied. Retail's HashTable::add tolerates the duplicate; Dictionary
throws. OpenNextDialog now returns early when the queue key is already
active — the reentrant dialog's own eventual close drains the queue.

Should-fix 2: @join/@leave wrote the local RuntimeCharacterOptionsState bit
before sending, but the Settings Chat toggles reached a second binding
(SendSingleCharacterOption) that only sent the wire message, leaving the
Turbine membership gate stale until the next PlayerDescription.
LiveSessionRuntimeFactory.CreateCommandBindings now has one shared local
function for both entrances.

Should-fix 3: corrected TS-68/#360 wording again — retail's DoAllegiance
dispatcher table EXECUTES boot/ban/officer/title/motd/name/lock/house/
chat/broadcast locally through their own handlers; acdream shows the
unrecognized-subcommand refusal for all nine pending the #360 port. What
matches retail is the ownership rule (the verb never reaches
DoChannelCommand/the server), not the subcommand behavior itself. Removed
the inaccurate "matching retail, not merely harmless" / "now matches
this" claims from both the register row and the issue.

Nits: corrected the HouseAbandonDialogCallback_First citation (0x00580E1A
is DoHouse's load site for the callback pointer, not the function entry —
the entry is 0x00580240, with the stage-2 confirmation string built at
0x005802D8) in both ClientCommandController.cs and the mirrored test
comment; added an InlineData case pinning "@clist allegiance" to
RequestChannelList(0x02000000); converted RetailClientCommandCatalog.
KnownVerbs from a plain array to a FrozenSet<string> with
StringComparer.OrdinalIgnoreCase, matching the file's other lookup tables.

Suite: 12,221 passed / 4 skipped / 0 failed (Release), up from CH4's
12,216/4/0 — net +5 tests, no removals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 22:24:43 +02:00
parent 59c053ee47
commit 5d247d5518
10 changed files with 204 additions and 34 deletions

View file

@ -325,7 +325,31 @@ internal sealed class LiveSessionRuntimeFactory
}
private LiveSessionCommandBindings CreateCommandBindings(
WorldSession session) => new(
WorldSession session)
{
// CH4 re-review SHOULD-FIX 2 (2026-08-09): single local-write
// chokepoint for retail's SetSingleCharacterOption (0x0005) local
// option-bit write, reached by BOTH entrances that can flip a
// character option — @join/@leave
// (ClientCommandController.Bindings.SetSingleCharacterOption below)
// and the Settings Chat toggles (LiveSessionCommandBindings.
// SendSingleCharacterOption at the bottom of this method, routed
// through RuntimeSettingsController.PublishHearOptionChange ->
// RuntimeSettingsTargets.SetSingleCharacterOption ->
// SetSingleCharacterOptionRuntimeCmd -> LiveSessionCommandRouter).
// Retail's PlayerModule::SetHear*Chat family writes the bit into the
// local options copy FIRST, then notifies the server, for both
// entrances alike — routing only @join/@leave through the local
// write (the prior CH4 fix) left a Settings-route toggle stale in
// TurbineChatMembershipGate (which reads _domain.Character.Options)
// until the next PlayerDescription happened to arrive.
void SendSingleCharacterOption(uint optionId, bool value)
{
_domain.Character.Options.SetOptionBit(optionId, value);
session.SendSetSingleCharacterOption(optionId, value);
}
return new(
ClientCommands: new ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
@ -435,18 +459,16 @@ internal sealed class LiveSessionRuntimeFactory
// silent accept is exactly as faithful as a stored-but-unread
// value would be, without inventing a consumer.
SetChatTitle: _ => { },
// CH4 REJECT-review SHOULD-FIX 4 (2026-08-09): only @join/@leave
// reach this binding (see ClientCommandController.Execute).
// Retail's PlayerModule::SetHear*Chat family writes the bit into
// the local options copy FIRST, then notifies the server — match
// CH4 re-review SHOULD-FIX 2 (2026-08-09): @join/@leave route
// through the same SendSingleCharacterOption local function as
// the Settings-route binding below — see the chokepoint comment
// at the top of CreateCommandBindings. Retail's
// PlayerModule::SetHear*Chat family writes the bit into the
// local options copy FIRST, then notifies the server — match
// that ordering so TurbineChatMembershipGate (which reads
// _domain.Character.Options) stops refusing the newly-joined
// room before the next PlayerDescription happens to arrive.
SetSingleCharacterOption: (optionId, value) =>
{
_domain.Character.Options.SetOptionBit(optionId, value);
session.SendSetSingleCharacterOption(optionId, value);
},
SetSingleCharacterOption: SendSingleCharacterOption,
AddPlayerPermission: session.SendAddPlayerPermission,
RemovePlayerPermission: session.SendRemovePlayerPermission,
RequestAvailableHouses: session.SendListAvailableHouses,
@ -497,8 +519,9 @@ internal sealed class LiveSessionRuntimeFactory
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Communication: _domain.Communication,
CharacterState: _domain.Character,
SendSingleCharacterOption: session.SendSetSingleCharacterOption,
SendSingleCharacterOption: SendSingleCharacterOption,
Log: _log);
}
private static double ClientTimerNow() =>
Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;