feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity

Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 19:39:44 +02:00
parent fc9590e4fc
commit 614a1e055f
35 changed files with 1453 additions and 229 deletions

View file

@ -322,6 +322,14 @@ internal sealed class SessionPlayerCompositionPhase
Fault(SessionPlayerCompositionPoint.StreamingCreated);
bindings = new SessionPlayerRuntimeBindings();
// CH3 (2026-08-09): constructed here (rather than at its original
// site further down, alongside LiveSessionAppSource) purely so
// RuntimeSettingsTargets can publish SetSingleCharacterOption
// through the SAME stable command surface the retained UI uses —
// LiveSessionCommandSurface has no dependencies of its own, so
// hoisting its construction is inert; the later site now reuses
// this instance instead of constructing a second one.
var liveSessionCommands = new LiveSessionCommandSurface();
var settingsTargets = new RuntimeSettingsTargets(
new SilkRuntimeDisplayWindowTarget(d.Window),
live.DrawDispatcher,
@ -329,6 +337,7 @@ internal sealed class SessionPlayerCompositionPhase
streaming,
d.RenderRange,
interaction.RetainedUi?.Host.Root,
liveSessionCommands,
d.Log);
bindings.Adopt(
"runtime settings targets",
@ -419,6 +428,7 @@ internal sealed class SessionPlayerCompositionPhase
worldReveal,
spawnClaimClassifier,
bindings,
liveSessionCommands,
ref bindingsOwnedByScope);
}
@ -436,6 +446,10 @@ internal sealed class SessionPlayerCompositionPhase
WorldRevealCoordinator worldReveal,
DatSpawnClaimHydrationClassifier spawnClaimClassifier,
SessionPlayerRuntimeBindings bindings,
// CH3 (2026-08-09): threaded through from ComposeCore, where it was
// constructed early (alongside RuntimeSettingsTargets) instead of at
// its original site below — see the construction-site comment.
LiveSessionCommandSurface liveSessionCommands,
ref bool bindingsOwnedByScope)
{
SessionPlayerDependencies d = _dependencies;
@ -474,7 +488,9 @@ internal sealed class SessionPlayerCompositionPhase
d.Log);
LiveSessionController liveSession = d.Runtime.Session;
var liveSessionCommands = new LiveSessionCommandSurface();
// liveSessionCommands arrives as a parameter — constructed in
// ComposeCore alongside RuntimeSettingsTargets (CH3, 2026-08-09) and
// threaded through, not reconstructed here.
var liveSessionSource = new LiveSessionAppSource(
liveSession,
liveSessionCommands);

View file

@ -2,6 +2,7 @@ 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;
@ -28,7 +29,6 @@ internal sealed record LiveSessionCommandBindings(
Action<uint, ulong> RaiseVital,
Action<uint, ulong> RaiseSkill,
Action<uint, uint> TrainSkill,
Action<uint> SetCharacterOptions,
Action<string> AddFriend,
Action<uint> RemoveFriend,
Action ClearFriends,
@ -36,6 +36,15 @@ internal sealed record LiveSessionCommandBindings(
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> 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<uint, bool> SendSingleCharacterOption,
Action<string>? Log = null);
internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry);
@ -57,7 +66,9 @@ 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 SetCharacterOptionsRuntimeCmd(uint Options);
internal readonly record struct SetSingleCharacterOptionRuntimeCmd(
uint OptionId,
bool Value);
internal readonly record struct AddFriendRuntimeCmd(string Name);
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
internal readonly record struct ClearFriendsRuntimeCmd;
@ -97,6 +108,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
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();
@ -145,9 +158,11 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
commands.Register<TrainSkillRuntimeCmd>(
command => SendIfActive(() =>
bindings.TrainSkill(command.StatId, command.Cost)));
commands.Register<SetCharacterOptionsRuntimeCmd>(
commands.Register<SetSingleCharacterOptionRuntimeCmd>(
command => SendIfActive(() =>
bindings.SetCharacterOptions(command.Options)));
bindings.SendSingleCharacterOption(
command.OptionId,
command.Value)));
commands.Register<AddFriendRuntimeCmd>(
command => SendIfActive(() => bindings.AddFriend(command.Name)));
commands.Register<RemoveFriendRuntimeCmd>(
@ -216,6 +231,28 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
commands?.Clear();
}
/// <summary>
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
/// is legacy-only (0x0147) — the two pipelines never overlap, so this
/// dispatch is exhaustive rather than "try Turbine, fall back to
/// legacy." That fallback was the bug (CH3 research doc §5.3): with no
/// allegiance, <c>/a</c> silently downgraded to the legacy
/// AllegianceBroadcast bitflag instead of retail's local refusal.
/// </summary>
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> 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)
@ -248,45 +285,100 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
return;
}
TurbineResolution? turbine = TurbineChatRouting.Resolve(
command.Channel,
bindings.TurbineChat);
if (turbine is not null)
if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind))
{
uint cookie = bindings.TurbineChat.NextContextId();
uint senderGuid = bindings.PlayerGuid();
bindings.Log?.Invoke(
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}");
SendIfActive(() => bindings.SendTurbineChat(
turbine.Value.RoomId,
turbine.Value.ChatType,
(uint)TurbineChat.DispatchType.SendToRoomById,
senderGuid,
command.Text,
cookie));
RouteTurbineChat(bindings, liteKind, command.Text);
return;
}
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel);
RouteLegacyChannel(bindings, command.Channel, command.Text);
}
/// <summary>
/// Step 2 of the CH3 fix list: retail
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
/// membership gate, raised through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
/// every other client-raised refusal.
/// </summary>
private void RouteTurbineChat(
LiveSessionCommandBindings bindings,
ChatChannelKindLite kind,
string text)
{
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
kind,
bindings.TurbineChat,
bindings.CharacterState.Options,
bindings.CharacterState.IsOlthoiPlayer);
switch (gate.Status)
{
case TurbineChatGateStatus.Unavailable:
bindings.Communication.AddText(
ClientTextRefusals.TurbineChatUnavailable,
RetailLogTextType.Default);
return;
case TurbineChatGateStatus.NotListening:
{
(string? refusal, RetailLogTextType type) =
WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName);
if (refusal is not null)
bindings.Communication.AddText(refusal, type);
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={command.Channel} dropped " +
$"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)");
$"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={command.Text.Length}");
$"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
if (!SendIfActive(() =>
bindings.SendChannel(legacy.Value.ChannelId, command.Text)))
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.
// AllegianceBroadcast includes the sender in its real-name broadcast
// with no such server echo, so it keeps the local echo (research
// doc §3.7/§5.4).
bool serverEchoes = new ChatChannelInfo.Legacy(
legacy.Value.ChannelId,
legacy.Value.DisplayName).IsSelfEchoChannel();
if (serverEchoes)
return;
bindings.Chat.OnSelfSent(
ChatKind.Channel,
command.Text,
text,
targetOrChannel: legacy.Value.DisplayName,
// Precise per-bit own-send type (LegacyChannelChatType.Resolve's
// ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/
@ -389,50 +481,3 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
}
}
}
internal readonly record struct TurbineResolution(
uint RoomId,
uint ChatType,
string DisplayName);
internal static class TurbineChatRouting
{
/// <summary>
/// Resolve the server-assigned Turbine room for one UI channel. This is
/// the existing holtburger <c>resolve_turbine_channel</c> mapping
/// (<c>references/holtburger/.../client/commands.rs</c>, lines 64-98),
/// moved intact from GameWindow with its runtime-room gate preserved.
/// </summary>
public static TurbineResolution? Resolve(
ChatChannelKind kind,
TurbineChatState state)
{
ArgumentNullException.ThrowIfNull(state);
if (!state.Enabled)
return null;
(uint Room, uint ChatType, string Name) = kind switch
{
ChatChannelKind.Allegiance =>
(state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"),
ChatChannelKind.General =>
(state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"),
ChatChannelKind.Trade =>
(state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"),
ChatChannelKind.Lfg =>
(state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"),
ChatChannelKind.Roleplay =>
(state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"),
ChatChannelKind.Society =>
(state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"),
ChatChannelKind.Olthoi =>
(state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"),
_ => (0u, 0u, string.Empty),
};
return Room == 0u
? null
: new TurbineResolution(Room, ChatType, Name);
}
}

View file

@ -310,7 +310,13 @@ internal sealed class LiveSessionRuntimeFactory
// (pseudocode doc §8/§9). C3c-F1: that seam is now the Runtime
// movement owner's typed application entry — see
// LiveMovementStatsApplier.
OnMovementStatsUpdated: () => _movementStats.Apply("stats"));
OnMovementStatsUpdated: () => _movementStats.Apply("stats"),
// Campaign CH slice CH3 (2026-08-09): reseed the Settings Chat
// draft from server truth every time a PlayerDescription lands
// (research doc §5.2/§6.4 — the local ChatSettings.Default lied
// relative to ACE's CharacterOptions2.Default).
OnCharacterOptionsChanged: (_, options2) =>
_interaction.Settings.SyncChatFromServerOptions(options2));
}
private LiveSessionCommandBindings CreateCommandBindings(
@ -448,7 +454,6 @@ internal sealed class LiveSessionRuntimeFactory
RaiseVital: session.SendRaiseVital,
RaiseSkill: session.SendRaiseSkill,
TrainSkill: session.SendTrainSkill,
SetCharacterOptions: session.SendSetCharacterOptions,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
@ -456,6 +461,9 @@ internal sealed class LiveSessionRuntimeFactory
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
Communication: _domain.Communication,
CharacterState: _domain.Character,
SendSingleCharacterOption: session.SendSetSingleCharacterOption,
Log: _log);
private static double ClientTimerNow() =>

View file

@ -665,14 +665,15 @@ internal sealed class CurrentGameRuntimeCommandAdapter
command.StatId);
}
public RuntimeCommandResult SetOptions1(
public RuntimeCommandResult SetSingleOption(
RuntimeGenerationToken expectedGeneration,
uint options)
uint optionId,
bool value)
{
RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true);
if (gate != RuntimeCommandStatus.Accepted)
return Result(gate);
_commands.Publish(new SetCharacterOptionsRuntimeCmd(options));
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
return EmitResult(
RuntimeCommandDomain.Character,
operation: 4,
@ -835,6 +836,7 @@ internal sealed class CurrentGameRuntimeCommandAdapter
RuntimeChatChannel.Tell => ChatChannelKind.Tell,
RuntimeChatChannel.Fellowship => ChatChannelKind.Fellowship,
RuntimeChatChannel.Allegiance => ChatChannelKind.Allegiance,
RuntimeChatChannel.AllegianceBroadcast => ChatChannelKind.AllegianceBroadcast,
RuntimeChatChannel.Vassals => ChatChannelKind.Vassals,
RuntimeChatChannel.Patron => ChatChannelKind.Patron,
RuntimeChatChannel.Monarch => ChatChannelKind.Monarch,

View file

@ -1,4 +1,5 @@
using AcDream.App.Combat;
using AcDream.Core.Net.Messages;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
@ -121,6 +122,15 @@ internal interface IRuntimeSettingsTargets
void ApplyQuality(QualitySettings quality);
void ApplyUiLock(bool locked);
/// <summary>
/// Campaign CH slice CH3 (2026-08-09): the generation-gated seam
/// (matching J4.4's pattern) that publishes retail's
/// <c>SetSingleCharacterOption (0x0005)</c> for one Settings Chat
/// toggle. <paramref name="optionId"/> is an ACE
/// <c>CharacterOption</c> id (e.g. <c>ListenToGeneralChat = 0x23</c>).
/// </summary>
void SetSingleCharacterOption(uint optionId, bool value);
}
internal interface IRuntimeSettingsPreviewSource
@ -499,6 +509,7 @@ internal sealed class RuntimeSettingsController :
private void SaveChat(ChatSettings chat)
{
ChatSettings previous = Chat;
try
{
_storage.SaveChat(chat);
@ -508,6 +519,81 @@ internal sealed class RuntimeSettingsController :
catch (Exception ex)
{
_log($"settings: chat save failed: {ex.Message}");
return;
}
// CH3 (2026-08-09): retail toggles a Hear*Chat option and pushes
// SetSingleCharacterOption (0x0005) in the same step (mirrors
// SaveGameplay's ApplyUiLock push above) — ACE's handler both flips
// the option AND joins/leaves the matching Turbine room.
PublishHearOptionChange(
previous.HearGeneralChat, chat.HearGeneralChat,
(uint)CharacterOptionId.ListenToGeneralChat);
PublishHearOptionChange(
previous.HearTradeChat, chat.HearTradeChat,
(uint)CharacterOptionId.ListenToTradeChat);
PublishHearOptionChange(
previous.HearLFGChat, chat.HearLFGChat,
(uint)CharacterOptionId.ListenToLFGChat);
PublishHearOptionChange(
previous.HearRoleplayChat, chat.HearRoleplayChat,
(uint)CharacterOptionId.ListenToRoleplayChat);
PublishHearOptionChange(
previous.HearSocietyChat, chat.HearSocietyChat,
(uint)CharacterOptionId.ListenToSocietyChat);
}
private void PublishHearOptionChange(bool previous, bool current, uint optionId)
{
if (previous == current)
return;
_runtimeTargets?.SetSingleCharacterOption(optionId, current);
}
/// <summary>
/// CH3 (2026-08-09): reseed the persisted + draft Chat snapshot from the
/// server's own <c>CharacterOptions2</c> bitfield (already parsed out of
/// PlayerDescription) — called whenever a fresh description lands. The
/// local <see cref="ChatSettings.Default"/> lies relative to ACE's
/// default (Roleplay/Society start OFF server-side), so this is the only
/// way the checkbox ever reflects truth for a character that never
/// explicitly saved a Chat preference.
/// </summary>
public void SyncChatFromServerOptions(uint options2)
{
// Applied identically to BOTH the persisted snapshot and the live
// draft (mirrors ApplyExternalGameplayChange's own idempotent-update
// shape) so an unsaved draft edit to an unrelated field (font size,
// timestamps, ...) survives the reseed instead of being clobbered by
// a value computed once against the persisted snapshot.
ChatSettings Reseed(ChatSettings current) => current with
{
HearGeneralChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat) != 0u,
HearTradeChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat) != 0u,
HearLFGChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat) != 0u,
HearRoleplayChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat) != 0u,
HearSocietyChat = (options2
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat) != 0u,
};
ChatSettings synced = Reseed(Chat);
if (synced == Chat)
return;
Chat = synced;
_viewModel?.ApplyExternalChatChange(Reseed);
try
{
_storage.SaveChat(synced);
_log($"settings: chat synced from server options2=0x{options2:X8}");
}
catch (Exception ex)
{
_log($"settings: chat sync save failed: {ex.Message}");
}
}

View file

@ -1,8 +1,10 @@
using AcDream.App.Audio;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.App.Streaming;
using AcDream.App.UI;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Settings;
using AcDream.UI.Abstractions.Settings;
using Silk.NET.Maths;
@ -203,6 +205,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
private readonly IRuntimeDisplayWindowTarget _displayWindow;
private readonly IRuntimeQualityApplicationTarget _quality;
private readonly IRuntimeUiLockTarget _uiLock;
private readonly ICommandBus _commands;
private readonly Action<string> _log;
public RuntimeSettingsTargets(
@ -212,6 +215,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
StreamingController streaming,
WorldRenderRangeState renderRange,
UiRoot? uiRoot,
ICommandBus commands,
Action<string>? log = null)
: this(
displayWindow,
@ -223,6 +227,7 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
uiRoot is null
? NullRuntimeUiLockTarget.Instance
: new RuntimeUiLockTarget(uiRoot),
commands,
log)
{
}
@ -231,12 +236,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
IRuntimeDisplayWindowTarget displayWindow,
IRuntimeQualityApplicationTarget quality,
IRuntimeUiLockTarget uiLock,
ICommandBus commands,
Action<string>? log = null)
{
_displayWindow = displayWindow
?? throw new ArgumentNullException(nameof(displayWindow));
_quality = quality ?? throw new ArgumentNullException(nameof(quality));
_uiLock = uiLock ?? throw new ArgumentNullException(nameof(uiLock));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? Console.WriteLine;
}
@ -257,4 +264,14 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets
}
public void ApplyUiLock(bool locked) => _uiLock.Apply(locked);
/// <summary>
/// CH3 (2026-08-09): publishes through the SAME
/// <see cref="LiveSessionCommandRouter"/> generation-gated route every
/// other outbound Settings/chat command uses — a no-op when no route is
/// currently attached (disconnected / reconnecting), exactly like every
/// other <c>ICommandBus.Publish</c> call site.
/// </summary>
public void SetSingleCharacterOption(uint optionId, bool value) =>
_commands.Publish(new SetSingleCharacterOptionRuntimeCmd(optionId, value));
}