using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; using AcDream.UI.Abstractions; namespace AcDream.App.Net; internal sealed record LiveSessionCommandBindings( ClientCommandController.Bindings ClientCommands, ChatLog Chat, TurbineChatState TurbineChat, Func PlayerGuid, Action SendTalk, Action SendTell, Action SendChannel, Action SendTurbineChat, Action AddShortcut, Action RemoveShortcut, Action AddFavorite, Action RemoveFavorite, Action SetSpellbookFilter, Action ForgetSpell, Action SetDesiredComponent, Action ClearDesiredComponents, Action RaiseAttribute, Action RaiseVital, Action RaiseSkill, Action TrainSkill, Action AddFriend, Action RemoveFriend, Action ClearFriends, Action RequestLegacyFriends, // Secure trade (2026-08-14) — the CM_Trade senders. Action OpenTradeNegotiations, Action CloseTradeNegotiations, Action AddToTrade, Action AcceptTrade, Action DeclineTrade, Action ResetTrade, Action ModifyCharacterSquelch, Action ModifyAccountSquelch, Action ModifyGlobalSquelch, // Campaign CH slice CH3 (2026-08-09): the RuntimeCommunicationState. // AddText chokepoint (local refusals — Turbine unavailable / not // listening) and the RuntimeCharacterState owner (Hear*Chat options + // IsOlthoiPlayer) the Turbine membership gate reads, plus the // SetSingleCharacterOption (0x0005) sender that replaced the malformed, // callerless full-blob SetCharacterOptions (0x01A1) path. RuntimeCommunicationState Communication, RuntimeCharacterState CharacterState, Action SendSingleCharacterOption, // Campaign OP slice OP1 (2026-08-10): the real SetCharacterOptions // (0x01A1) blob-flush verb — CH3's TODO, now resurrected per wire // research §2.3-§2.7. No-ops when the batched module is clean, matching // retail's CPlayerModule::SaveToServer(force: 0). Action SaveCharacterOptions, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's // direct session.SendXxx calls. Action SendFellowshipCreate, Action SendFellowshipRecruit, Action SendFellowshipDismiss, Action SendFellowshipQuit, Action SendFellowshipAssignNewLeader, Action SendFellowshipChangeOpenness, Action SendFellowshipUpdateRequest, Action SendAllegianceSwear, Action SendAllegianceBreak, Action SendAllegianceKick, Action SendAllegianceInfoRequest, Action SendAllegianceUpdateRequest, Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); internal readonly record struct AddFavoriteRuntimeCmd( uint SpellId, int Position, int TabIndex); internal readonly record struct RemoveFavoriteRuntimeCmd( uint SpellId, int TabIndex); internal readonly record struct SetSpellbookFilterRuntimeCmd(uint Filters); internal readonly record struct ForgetSpellRuntimeCmd(uint SpellId); internal readonly record struct SetDesiredComponentRuntimeCmd( uint ComponentId, uint Amount); internal readonly record struct ClearDesiredComponentsRuntimeCmd; internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); internal readonly record struct SetSingleCharacterOptionRuntimeCmd( uint OptionId, bool Value); internal readonly record struct SaveCharacterOptionsRuntimeCmd; internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); // ── Secure trade (2026-08-14) ────────────────────────────────────────────── internal readonly record struct OpenTradeNegotiationsRuntimeCmd(uint PartnerGuid); internal readonly record struct CloseTradeNegotiationsRuntimeCmd; internal readonly record struct AddToTradeRuntimeCmd(uint ItemGuid); internal readonly record struct AcceptTradeRuntimeCmd( uint PartnerGuid, bool SelfAccepted, bool PartnerAccepted); internal readonly record struct DeclineTradeRuntimeCmd; internal readonly record struct ResetTradeRuntimeCmd; internal readonly record struct ClearFriendsRuntimeCmd; internal readonly record struct RequestLegacyFriendsRuntimeCmd; internal readonly record struct ModifyCharacterSquelchRuntimeCmd( bool Add, uint CharacterId, string Name, uint MessageType); internal readonly record struct ModifyAccountSquelchRuntimeCmd( bool Add, string Name); internal readonly record struct ModifyGlobalSquelchRuntimeCmd( bool Add, uint MessageType); // ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ──────────── internal readonly record struct FellowshipCreateRuntimeCmd( string FellowshipName, bool ShareXp); internal readonly record struct FellowshipRecruitRuntimeCmd(uint TargetGuid); internal readonly record struct FellowshipDismissRuntimeCmd(uint TargetGuid); internal readonly record struct FellowshipQuitRuntimeCmd(bool Disband); internal readonly record struct FellowshipAssignNewLeaderRuntimeCmd(uint NewLeaderGuid); internal readonly record struct FellowshipChangeOpennessRuntimeCmd(bool IsOpen); internal readonly record struct FellowshipUpdateRequestRuntimeCmd(bool PanelOpen); internal readonly record struct AllegianceSwearRuntimeCmd(uint PatronGuid); internal readonly record struct AllegianceBreakRuntimeCmd(uint TargetGuid); internal readonly record struct AllegianceKickRuntimeCmd(uint VassalGuid); internal readonly record struct AllegianceInfoRequestRuntimeCmd(string PlayerName); internal readonly record struct AllegianceUpdateRequestRuntimeCmd(bool On); /// /// Owns the command surface for one exact live-session generation. The router /// itself is the published bus, so a retained reference becomes inert before /// the displaced transport is disposed. /// internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { private readonly object _gate = new(); private LiveCommandBus? _commands; private ClientCommandController.Bindings? _clientCommands; private int _state; // 0 = constructed, 1 = active, 2 = disposed public LiveSessionCommandRouter(LiveSessionCommandBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); ArgumentNullException.ThrowIfNull(bindings.ClientCommands); ArgumentNullException.ThrowIfNull(bindings.Chat); ArgumentNullException.ThrowIfNull(bindings.TurbineChat); ArgumentNullException.ThrowIfNull(bindings.PlayerGuid); ArgumentNullException.ThrowIfNull(bindings.SendTalk); ArgumentNullException.ThrowIfNull(bindings.SendTell); ArgumentNullException.ThrowIfNull(bindings.SendChannel); ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); ArgumentNullException.ThrowIfNull(bindings.Communication); ArgumentNullException.ThrowIfNull(bindings.CharacterState); _clientCommands = bindings.ClientCommands; var commands = new LiveCommandBus(); var clientCommands = new ClientCommandController( BuildGuardedClientCommands(bindings.ClientCommands)); commands.Register(clientCommands.Execute); commands.Register(command => { if (!string.IsNullOrEmpty(command.Text)) SendIfActive(() => bindings.SendTalk(command.Text)); }); commands.Register(command => RouteChat(bindings, command)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw // legacy ChatChannel (0x0147) broadcast directly. commands.Register( command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text))); commands.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( command => SendIfActive(() => bindings.RemoveShortcut(command.Index))); commands.Register( command => SendIfActive(() => bindings.AddFavorite( command.SpellId, command.Position, command.TabIndex))); commands.Register( command => SendIfActive(() => bindings.RemoveFavorite( command.SpellId, command.TabIndex))); commands.Register( command => SendIfActive(() => bindings.SetSpellbookFilter(command.Filters))); commands.Register( command => SendIfActive(() => bindings.ForgetSpell(command.SpellId))); commands.Register( command => SendIfActive(() => bindings.SetDesiredComponent( command.ComponentId, command.Amount))); commands.Register( _ => SendIfActive(bindings.ClearDesiredComponents)); commands.Register( command => SendIfActive(() => bindings.RaiseAttribute(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.RaiseVital(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.RaiseSkill(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.TrainSkill(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.SendSingleCharacterOption( command.OptionId, command.Value))); commands.Register( _ => SendIfActive(bindings.SaveCharacterOptions)); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( command => SendIfActive(() => bindings.OpenTradeNegotiations(command.PartnerGuid))); commands.Register( _ => SendIfActive(bindings.CloseTradeNegotiations)); commands.Register( command => SendIfActive(() => bindings.AddToTrade(command.ItemGuid))); commands.Register( command => SendIfActive(() => bindings.AcceptTrade( command.PartnerGuid, command.SelfAccepted, command.PartnerAccepted))); commands.Register( _ => SendIfActive(bindings.DeclineTrade)); commands.Register( _ => SendIfActive(bindings.ResetTrade)); commands.Register( command => SendIfActive(() => bindings.RemoveFriend(command.CharacterId))); commands.Register( _ => SendIfActive(bindings.ClearFriends)); commands.Register( _ => SendIfActive(bindings.RequestLegacyFriends)); commands.Register( command => SendIfActive(() => bindings.ModifyCharacterSquelch( command.Add, command.CharacterId, command.Name, command.MessageType))); commands.Register( command => SendIfActive(() => bindings.ModifyAccountSquelch( command.Add, command.Name))); commands.Register( command => SendIfActive(() => bindings.ModifyGlobalSquelch( command.Add, command.MessageType))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipCreate( command.FellowshipName, command.ShareXp))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipRecruit(command.TargetGuid))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipDismiss(command.TargetGuid))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipQuit(command.Disband))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipAssignNewLeader(command.NewLeaderGuid))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipChangeOpenness(command.IsOpen))); commands.Register( command => SendIfActive(() => bindings.SendFellowshipUpdateRequest(command.PanelOpen))); commands.Register( command => SendIfActive(() => bindings.SendAllegianceSwear(command.PatronGuid))); commands.Register( command => SendIfActive(() => bindings.SendAllegianceBreak(command.TargetGuid))); commands.Register( command => SendIfActive(() => bindings.SendAllegianceKick(command.VassalGuid))); commands.Register( command => SendIfActive(() => bindings.SendAllegianceInfoRequest(command.PlayerName))); commands.Register( command => SendIfActive(() => bindings.SendAllegianceUpdateRequest(command.On))); _commands = commands; } public bool IsActive { get { lock (_gate) return _state == 1; } } public void Activate() { lock (_gate) { if (_state == 2) throw new ObjectDisposedException(nameof(LiveSessionCommandRouter)); _state = 1; } } public void Publish(T command) where T : notnull { lock (_gate) { if (_state == 1) _commands?.Publish(command); } } public void Dispose() { LiveCommandBus? commands; lock (_gate) { _state = 2; commands = _commands; _commands = null; _clientCommands = null; } commands?.Clear(); } /// /// The seven values that ride Turbine /// (0xF7DE), mapped to the lighter /// reads. Every OTHER channel /// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast) /// is legacy-only (0x0147) — those pipelines never overlap Turbine. /// is the one exception, and /// special-cases it BEFORE this table is /// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original /// CH3 filing (research doc §5.3) — retail's /a is bound to the /// LEGACY AllegianceBroadcast bitflag by default and is only /// rebound to DoTurbineChat_Allegiance once /// StartupTurbineChatSystem successfully starts Turbine chat /// (research doc §4.3). So "Turbine never started" (TurbineChat. /// Enabled == false) still falls back to legacy, while "Turbine is /// up but this character has no allegiance room" (Enabled == true, /// AllegianceRoom == 0) correctly keeps retail's local /// "Turbine chat is not available." refusal at the membership gate. /// private static readonly Dictionary TurbineChannelKinds = new() { [ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance, [ChatChannelKind.General] = ChatChannelKindLite.General, [ChatChannelKind.Trade] = ChatChannelKindLite.Trade, [ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg, [ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay, [ChatChannelKind.Society] = ChatChannelKindLite.Society, [ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi, }; private void RouteChat( LiveSessionCommandBindings bindings, SendChatCmd command) { if (string.IsNullOrEmpty(command.Text)) return; switch (command.Channel) { case ChatChannelKind.Say: // ACE echoes HearSpeech to the sender. Retail therefore uses // the authoritative inbound line rather than a local echo. SendIfActive(() => bindings.SendTalk(command.Text)); return; case ChatChannelKind.Tell: if (string.IsNullOrEmpty(command.TargetName)) return; if (!SendIfActive(() => bindings.SendTell(command.TargetName, command.Text))) return; bindings.Chat.OnSelfSent( ChatKind.Tell, command.Text, // Retail's own "You tell ..." echo is Speech_Direct_Send // (0x04), distinct from an incoming Tell's 0x03 — see // ChatMessageType.OutgoingTell's "You tell ..." comment. logTextType: (uint)RetailLogTextType.SpeechDirectSend, targetOrChannel: command.TargetName); return; } // S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc // comment above — Turbine chat never having started (no 0x0295 // SetTurbineChatChannels received at all) still routes /a through // the legacy AllegianceBroadcast bitflag, exactly like retail's // default binding before StartupTurbineChatSystem runs. if (command.Channel == ChatChannelKind.Allegiance && !bindings.TurbineChat.Enabled) { RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text); return; } if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind)) { RouteTurbineChat(bindings, liteKind, command.Text); return; } RouteLegacyChannel(bindings, command.Channel, command.Text); } /// /// Step 2 of the CH3 fix list: retail /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local /// membership gate, raised through the same /// RuntimeCommunicationState.AddText chokepoint CH2 built for /// every other client-raised refusal. /// private void RouteTurbineChat( LiveSessionCommandBindings bindings, ChatChannelKindLite kind, string text) { TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( kind, bindings.TurbineChat, bindings.CharacterState.Options, bindings.CharacterState.IsOlthoiPlayer); // N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is // now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via // TurbineChatMembershipGate.ResolveRefusalText — this used to be an // independent copy of the same switch. if (gate.Status != TurbineChatGateStatus.Allowed) { if (TurbineChatMembershipGate.ResolveRefusalText(gate) is (string refusalText, RetailLogTextType refusalType)) { bindings.Communication.AddText(refusalText, refusalType); } return; } uint cookie = bindings.TurbineChat.NextContextId(); uint senderGuid = bindings.PlayerGuid(); bindings.Log?.Invoke( $"chat: outbound TurbineChat {gate.DisplayName} " + $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}"); SendIfActive(() => bindings.SendTurbineChat( gate.RoomId, gate.ChatType, (uint)TurbineChat.DispatchType.SendToRoomById, senderGuid, text, cookie)); } private void RouteLegacyChannel( LiveSessionCommandBindings bindings, ChatChannelKind channel, string text) { ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel); if (legacy is null) { bindings.Log?.Invoke( $"chat: SendChatCmd kind={channel} dropped (no legacy id)"); return; } bindings.Log?.Invoke( $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}"); if (!SendIfActive(() => bindings.SendChannel(legacy.Value.ChannelId, text))) return; // Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends // Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an // empty sender name, so a local optimistic echo double-prints. S1 // (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into // this SAME group: ACE's GameActionChatChannel handler iterates // player.Allegiance.Members and the sender is one of them, so they // get their own line back with their real name too — a different // mechanism (no separate ""-sender resend) but the same // double-print risk, so it must ALSO skip the local echo (research // doc §3.7/§5.4, corrected). bool serverEchoes = new ChatChannelInfo.Legacy( legacy.Value.ChannelId, legacy.Value.DisplayName).IsSelfEchoChannel(); if (serverEchoes) return; bindings.Chat.OnSelfSent( ChatKind.Channel, text, targetOrChannel: legacy.Value.DisplayName, // Precise per-bit own-send type (LegacyChannelChatType.Resolve's // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ // Vassal/Follower become 0x0B, the admin/audit/sentinel // catch-all becomes 0x09 Channel_Send (corrected 2026-08-09, // Opus review of 172c6f9a — was wrongly 0x0E). logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true)); } private ClientCommandController.Bindings BuildGuardedClientCommands( ClientCommandController.Bindings source) => new( TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()), TeleportToMarketplace: () => InvokeClient(static b => b.TeleportToMarketplace()), TeleportToPkArena: () => InvokeClient(static b => b.TeleportToPkArena()), TeleportToPkLiteArena: () => InvokeClient(static b => b.TeleportToPkLiteArena()), TeleportToHouse: () => InvokeClient(static b => b.TeleportToHouse()), TeleportToMansion: () => InvokeClient(static b => b.TeleportToMansion()), QueryAge: () => InvokeClient(static b => b.QueryAge()), QueryBirth: () => InvokeClient(static b => b.QueryBirth()), ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()), ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()), ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)), ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)), PlayerPublicWeenieBitfield: () => ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)), ClientVersion: () => ReadClient(static b => b.ClientVersion(), string.Empty), CurrentPosition: () => ReadClient(static b => b.CurrentPosition(), default(AcDream.Core.Physics.Position?)), LastOutsideCorpsePosition: () => ReadClient(static b => b.LastOutsideCorpsePosition(), default(AcDream.Core.Physics.Position?)), ShowConfirmation: (text, callback) => InvokeClient(b => b.ShowConfirmation(text, callback)), Suicide: () => InvokeClient(static b => b.Suicide()), ClearChat: all => InvokeClient(b => b.ClearChat(all)), SaveUi: name => InvokeClient(b => b.SaveUi(name)), LoadUi: name => InvokeClient(b => b.LoadUi(name)), SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()), LoadAutoUi: () => InvokeClient(static b => b.LoadAutoUi()), IsAway: () => ReadClient(static b => b.IsAway(), false), SetAway: away => InvokeClient(b => b.SetAway(away)), SetAwayMessage: message => InvokeClient(b => b.SetAwayMessage(message)), AcceptLootPermits: () => ReadClient(static b => b.AcceptLootPermits(), false), SetAcceptLootPermits: accept => InvokeClient(b => b.SetAcceptLootPermits(accept)), DisplayConsent: () => InvokeClient(static b => b.DisplayConsent()), ClearConsent: () => InvokeClient(static b => b.ClearConsent()), RemoveConsent: name => InvokeClient(b => b.RemoveConsent(name)), SendEmote: text => InvokeClient(b => b.SendEmote(text)), Friends: source.Friends, AddFriend: name => InvokeClient(b => b.AddFriend(name)), RemoveFriend: id => InvokeClient(b => b.RemoveFriend(id)), ClearFriends: () => InvokeClient(static b => b.ClearFriends()), RequestLegacyFriends: () => InvokeClient(static b => b.RequestLegacyFriends()), Squelch: source.Squelch, ModifyCharacterSquelch: (add, id, name, chatType) => InvokeClient(b => b.ModifyCharacterSquelch(add, id, name, chatType)), ModifyAccountSquelch: (add, name) => InvokeClient(b => b.ModifyAccountSquelch(add, name)), ModifyGlobalSquelch: (add, chatType) => InvokeClient(b => b.ModifyGlobalSquelch(add, chatType)), LastTeller: () => ReadClient(static b => b.LastTeller(), default(string?)), ClearDesiredComponents: () => InvokeClient(static b => b.ClearDesiredComponents()), HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false), FillComponentBuyList: (componentId, targetCount) => InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)), EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()), // Campaign CH slice CH4 (2026-08-09): command-registry completion. IsUsingTurbineChat: () => ReadClient(static b => b.IsUsingTurbineChat(), false), SetChatTitle: title => InvokeClient(b => b.SetChatTitle(title)), SetSingleCharacterOption: (optionId, value) => InvokeClient(b => b.SetSingleCharacterOption(optionId, value)), AddPlayerPermission: name => InvokeClient(b => b.AddPlayerPermission(name)), RemovePlayerPermission: name => InvokeClient(b => b.RemovePlayerPermission(name)), RequestAvailableHouses: houseType => InvokeClient(b => b.RequestAvailableHouses(houseType)), RequestChannelIndex: () => InvokeClient(static b => b.RequestChannelIndex()), RequestChannelList: channelId => InvokeClient(b => b.RequestChannelList(channelId)), JoinGmChannel: channelId => InvokeClient(b => b.JoinGmChannel(channelId)), LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)), RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()), RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)), AbandonHouse: () => InvokeClient(static b => b.AbandonHouse())); private bool InvokeClient(Action invoke) { lock (_gate) { ClientCommandController.Bindings? bindings = _clientCommands; if (_state != 1 || bindings is null) return false; invoke(bindings); return true; } } private TResult ReadClient( Func read, TResult fallback) { lock (_gate) { ClientCommandController.Bindings? bindings = _clientCommands; return _state == 1 && bindings is not null ? read(bindings) : fallback; } } private bool SendIfActive(Action send) { lock (_gate) { if (_state != 1) return false; send(); return true; } } }