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:
parent
fc9590e4fc
commit
614a1e055f
35 changed files with 1453 additions and 229 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -207,10 +207,35 @@ public static class PlayerDescriptionParser
|
|||
{
|
||||
None = 0,
|
||||
AllowGive = 0x00000040,
|
||||
// Campaign CH slice CH3 (2026-08-09): retail's Turbine-room
|
||||
// membership gate for the Allegiance chat room
|
||||
// (ClientCommunicationSystem::SendTurbineChat @0x0057db10 reads
|
||||
// PlayerModule::HearAllegianceChat as its "hearOption" argument for
|
||||
// DoTurbineChat_Allegiance). ACE: CharacterOptions1.cs.
|
||||
HearAllegianceChat = 0x40000000,
|
||||
DragItemOnPlayerOpensSecureTrade = 0x04000000,
|
||||
Default = 0x50C4A54A,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second character-option bitfield (the trailer's
|
||||
/// <c>CharacterOptions2</c> u32, present when
|
||||
/// <see cref="CharacterOptionDataFlag.CharacterOptions2"/> is set).
|
||||
/// Campaign CH slice CH3 (2026-08-09): only the five <c>Hear*Chat</c>
|
||||
/// membership bits retail's <c>SendTurbineChat</c> reads are modeled
|
||||
/// here — ACE <c>CharacterOptions2.cs</c> has the complete bitfield.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum CharacterOptions2 : uint
|
||||
{
|
||||
None = 0,
|
||||
HearGeneralChat = 0x00000100,
|
||||
HearTradeChat = 0x00000200,
|
||||
HearLFGChat = 0x00000400,
|
||||
HearRoleplayChat = 0x00000800,
|
||||
HearSocietyChat = 0x00080000,
|
||||
}
|
||||
|
||||
/// <summary>One inventory entry — a guid plus a ContainerType
|
||||
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
|
||||
/// <c>events.rs:143-168</c> validates <c>ContainerType <= 2</c>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// References: r08 §3 rows for each opcode.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// ACE <c>CharacterOption</c> ids (a LINEAR enum, distinct from the
|
||||
/// <c>CharacterOptions1</c>/<c>CharacterOptions2</c> BITFIELDS) — the first
|
||||
/// <c>u32</c> of a <c>SetSingleCharacterOption (0x0005)</c> payload. Only
|
||||
/// the six <c>ListenTo*Chat</c> ids Campaign CH slice CH3 (2026-08-09) needs
|
||||
/// are modeled here; ACE <c>Source/ACE.Entity/Enum/CharacterOption.cs</c>
|
||||
/// has the complete list.
|
||||
/// </summary>
|
||||
public enum CharacterOptionId : uint
|
||||
{
|
||||
ListenToAllegianceChat = 0x1B,
|
||||
ListenToGeneralChat = 0x23,
|
||||
ListenToTradeChat = 0x24,
|
||||
ListenToLFGChat = 0x25,
|
||||
ListenToRoleplayChat = 0x26,
|
||||
ListenToSocietyChat = 0x2E,
|
||||
}
|
||||
|
||||
public static class SocialActions
|
||||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
|
|
@ -40,11 +58,15 @@ public static class SocialActions
|
|||
public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
|
||||
|
||||
// Character options
|
||||
public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap
|
||||
|
||||
// Chat channels
|
||||
public const uint AddChannelOpcode = 0x0145u; // string16L channelName
|
||||
public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName
|
||||
// CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder
|
||||
// and the string-payload AddChannel/RemoveChannel (0x0145/0x0146)
|
||||
// builders were deleted here — none had a production caller, and all
|
||||
// three were malformed against ACE's real reader (research doc
|
||||
// 2026-08-09-chat-side-channels-vs-ace.md §3.8/§3.7/§5.5). Only
|
||||
// SetSingleCharacterOption (0x0005) — the message that actually
|
||||
// changes Turbine room membership — has a caller (WorldSession.
|
||||
// SendSetSingleCharacterOption), so only it is implemented.
|
||||
public const uint SetSingleCharacterOptionOpcode = 0x0005u; // u32 optionId, u32 value (0/1)
|
||||
|
||||
/// <summary>Query a target's health — server replies with UpdateHealth (0x01C0).</summary>
|
||||
public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
|
||||
|
|
@ -132,25 +154,29 @@ public static class SocialActions
|
|||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Push the client's character-options bitmap to the server.</summary>
|
||||
public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
|
||||
/// <summary>
|
||||
/// Toggle one character option and push it to the server.
|
||||
/// <c>GameActionSetSingleCharacterOption @ GameActionType 0x0005</c> —
|
||||
/// the ONLY wire message that changes Turbine room membership: for the
|
||||
/// <c>ListenTo*Chat</c> option ids, ACE's handler both flips the option
|
||||
/// AND calls <c>JoinTurbineChatChannel</c>/<c>LeaveTurbineChatChannel</c>
|
||||
/// (re-pushing <c>SetTurbineChatChannels</c>). Payload
|
||||
/// <c>u32 option, u32 value</c> confirmed against ACE
|
||||
/// (<c>GameActionSetSingleCharacterOption.cs:11-12</c>) and holtburger
|
||||
/// (<c>SetSingleCharacterOptionActionData::pack</c>,
|
||||
/// <c>messages/player/actions.rs:149-157</c>) — both agree byte-for-byte.
|
||||
/// </summary>
|
||||
public static byte[] BuildSetSingleCharacterOption(uint seq, uint optionId, bool value)
|
||||
{
|
||||
byte[] body = new byte[16];
|
||||
byte[] body = new byte[20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetSingleCharacterOptionOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionId);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), value ? 1u : 0u);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>Subscribe to a named chat channel.</summary>
|
||||
public static byte[] BuildAddChannel(uint seq, string channelName)
|
||||
=> SingleString(seq, AddChannelOpcode, channelName);
|
||||
|
||||
/// <summary>Unsubscribe from a named chat channel.</summary>
|
||||
public static byte[] BuildRemoveChannel(uint seq, string channelName)
|
||||
=> SingleString(seq, RemoveChannelOpcode, channelName);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static byte[] SingleGuid(uint seq, uint sub, uint guid)
|
||||
|
|
@ -163,17 +189,6 @@ public static class SocialActions
|
|||
return body;
|
||||
}
|
||||
|
||||
private static byte[] SingleString(uint seq, uint sub, string s)
|
||||
{
|
||||
byte[] str = PackString16L(s);
|
||||
byte[] body = new byte[12 + str.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
|
||||
Array.Copy(str, 0, body, 12, str.Length);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static byte[] PackString16L(string s)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(s);
|
||||
|
|
|
|||
|
|
@ -2195,13 +2195,14 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail SetCharacterOptions (0x01A1) for the first character
|
||||
/// option bitmap.
|
||||
/// Send retail SetSingleCharacterOption (0x0005) — toggles one character
|
||||
/// option. For the six <c>ListenTo*Chat</c> ids this is the message that
|
||||
/// actually joins/leaves a Turbine room server-side (CH3, 2026-08-09).
|
||||
/// </summary>
|
||||
public void SendSetCharacterOptions(uint options)
|
||||
public void SendSetSingleCharacterOption(uint optionId, bool value)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(SocialActions.BuildSetCharacterOptions(seq, options));
|
||||
SendGameAction(SocialActions.BuildSetSingleCharacterOption(seq, optionId, value));
|
||||
}
|
||||
|
||||
public void SendAddFriend(string name)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,14 @@ public abstract record ChatChannelInfo(string DisplayName, ChatChannelSource Sou
|
|||
// channels are the ones the server echoes back to the sender
|
||||
// with an empty sender field. Bitflag values from
|
||||
// references/holtburger/.../messages/chat/types.rs::ChatChannel.
|
||||
//
|
||||
// CH3 (2026-08-09, research doc §3.7/§5.4): AllegianceBroadcast
|
||||
// (0x02000000) deliberately falls to the `false` default below —
|
||||
// ACE's GameActionChatChannel handler includes the sender in the
|
||||
// normal real-name member broadcast for that channel (no
|
||||
// separate "" -sender echo the way Fellow/Vassals/Patron/
|
||||
// Monarch/CoVassals get), so the client must keep its own local
|
||||
// optimistic echo or the sender never sees their own line.
|
||||
return ChannelId switch
|
||||
{
|
||||
0x00000800u => true, // Fellow
|
||||
|
|
|
|||
|
|
@ -106,4 +106,21 @@ public static class ClientTextRefusals
|
|||
/// "You can't use chat emotes from t…".
|
||||
/// </summary>
|
||||
public const string CantEmotePosition = "You can't use chat emotes from this position";
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): retail
|
||||
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
|
||||
/// refusal when Turbine chat is disabled or the target room id is 0 (no
|
||||
/// allegiance, no society, etc.) — raised BEFORE any wire send, exactly
|
||||
/// like the jump refusals above. Byte-read off the PDB-paired binary at
|
||||
/// <c>0x007e0d1c</c>: <c>"Turbine chat is not available.\n"</c>. The call
|
||||
/// site (<c>0057dd64</c>) passes type <c>0</c> (Default), not <c>0x1A</c>
|
||||
/// (ClientLocal) — confirmed by direct comparison against the
|
||||
/// unambiguous <c>cant_jump_in_air</c> call sites, which use the same
|
||||
/// <c>AddTextToScroll(this, &text, type, allowPluginFilter,
|
||||
/// windowId)</c> argument order with a literal <c>0x1a</c> in the type
|
||||
/// slot; this call has a literal <c>0</c> there instead. So this
|
||||
/// refusal lands in the chat transcript, not the SpewBox.
|
||||
/// </summary>
|
||||
public const string TurbineChatUnavailable = "Turbine chat is not available.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,17 @@ public enum RuntimeChatChannel
|
|||
Roleplay,
|
||||
Society,
|
||||
Olthoi,
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): the legacy
|
||||
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> — the monarch/
|
||||
/// speaker-permission broadcast bound to retail's <c>@ab</c> verb.
|
||||
/// Distinct from <see cref="Allegiance"/>, which is ALWAYS the Turbine
|
||||
/// room now (retail's <c>@a</c> binds unconditionally to
|
||||
/// <c>DoTurbineChat_Allegiance</c> once Turbine chat starts — see the
|
||||
/// research doc §4.3/§6.7).
|
||||
/// </summary>
|
||||
AllegianceBroadcast,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeChatCommand(
|
||||
|
|
@ -227,9 +238,18 @@ public interface IRuntimeCharacterCommands
|
|||
RuntimeGenerationToken expectedGeneration,
|
||||
in RuntimeAdvancementCommand command);
|
||||
|
||||
RuntimeCommandResult SetOptions1(
|
||||
/// <summary>
|
||||
/// Retail <c>SetSingleCharacterOption (GameActionType 0x0005)</c> —
|
||||
/// toggles one character option. Campaign CH slice CH3 (2026-08-09)
|
||||
/// replaced the former <c>SetOptions1</c> (the malformed, callerless
|
||||
/// full-Options1-blob 0x01A1 builder) with this: the only wire message
|
||||
/// that actually changes Turbine room membership for the six
|
||||
/// <c>ListenTo*Chat</c> ids.
|
||||
/// </summary>
|
||||
RuntimeCommandResult SetSingleOption(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint options);
|
||||
uint optionId,
|
||||
bool value);
|
||||
}
|
||||
|
||||
public enum RuntimeFriendCommandKind
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using AcDream.Core.Player;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
|
|
@ -107,6 +108,27 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
public IRuntimeCharacterView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): retail
|
||||
/// <c>PlayerModule::IsOlthoi</c> / ACE <c>Player.IsOlthoiPlayer</c>
|
||||
/// (<c>Player.cs:169</c>, <c>HeritageGroup == Olthoi ||
|
||||
/// HeritageGroup == OlthoiAcid</c>) — the gate
|
||||
/// <c>TurbineChatMembershipGate</c> consults for the Olthoi room instead
|
||||
/// of a <c>Hear*Chat</c> option (there is no such toggle; only heritage
|
||||
/// gates that room). Reads the already-parsed
|
||||
/// <c>PropertyInt.HeritageGroup (188)</c> off the local player's own
|
||||
/// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching
|
||||
/// every other heritage-gated check in this codebase.
|
||||
/// </summary>
|
||||
public bool IsOlthoiPlayer
|
||||
{
|
||||
get
|
||||
{
|
||||
int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0);
|
||||
return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>CommandInterpreter::GetAutonomyLevel</c>.</summary>
|
||||
public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel);
|
||||
|
||||
|
|
|
|||
143
src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs
Normal file
143
src/AcDream.Runtime/Gameplay/TurbineChatMembershipGate.cs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Net.Messages;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="TurbineChatMembershipGate.Evaluate"/>.
|
||||
/// </summary>
|
||||
public enum TurbineChatGateStatus
|
||||
{
|
||||
/// <summary>The send may proceed to the wire.</summary>
|
||||
Allowed,
|
||||
|
||||
/// <summary>
|
||||
/// Turbine chat is disabled, or this room's id is still 0 (not
|
||||
/// populated by <c>SetTurbineChatChannels</c> — e.g. no allegiance, no
|
||||
/// society). Retail: <c>"Turbine chat is not available."</c>
|
||||
/// (<see cref="ClientTextRefusals.TurbineChatUnavailable"/>), raised
|
||||
/// locally with NOTHING sent.
|
||||
/// </summary>
|
||||
Unavailable,
|
||||
|
||||
/// <summary>
|
||||
/// The room exists and Turbine chat is enabled, but the player's own
|
||||
/// <c>Hear*Chat</c> option (or, for Olthoi, heritage) is off. Retail:
|
||||
/// <c>WeenieErrorWithString 0x0551 YouAreNotListeningTo_Channel</c>,
|
||||
/// raised locally with NOTHING sent.
|
||||
/// </summary>
|
||||
NotListening,
|
||||
}
|
||||
|
||||
/// <summary>One evaluated gate decision, carrying the room/chatType/display
|
||||
/// name a caller needs whether the send proceeds or gets refused.</summary>
|
||||
public readonly record struct TurbineChatGateResult(
|
||||
TurbineChatGateStatus Status,
|
||||
uint RoomId,
|
||||
uint ChatType,
|
||||
string DisplayName);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's local pre-send membership gate for a Turbine-room chat channel,
|
||||
/// ported from <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>
|
||||
/// (Sept 2013 EoR build; research doc
|
||||
/// <c>docs/research/2026-08-09-chat-side-channels-vs-ace.md</c> §4.2/§6.2).
|
||||
///
|
||||
/// <para>
|
||||
/// Both the graphical (<c>LiveSessionCommandRouter</c>) and headless
|
||||
/// (<c>DirectGameRuntimeCommandAdapter</c>) outbound chat paths call this
|
||||
/// SAME chokepoint so the two hosts can never diverge on which channels are
|
||||
/// joined and which local refusal a blocked send produces. Retail refuses
|
||||
/// LOCALLY (nothing reaches the wire) in exactly two cases — Turbine chat
|
||||
/// off / room id 0, or the player's own <c>Hear*Chat</c> option off — before
|
||||
/// ever building a <c>TurbineChatBlob</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class TurbineChatMembershipGate
|
||||
{
|
||||
public static TurbineChatGateResult Evaluate(
|
||||
ChatChannelKindLite kind,
|
||||
TurbineChatState turbineChat,
|
||||
RuntimeCharacterOptionsState options,
|
||||
bool isOlthoiPlayer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(turbineChat);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
(uint room, uint chatType, string name) = kind switch
|
||||
{
|
||||
ChatChannelKindLite.Allegiance => (
|
||||
turbineChat.AllegianceRoom,
|
||||
(uint)TurbineChat.ChatType.Allegiance,
|
||||
"Allegiance"),
|
||||
ChatChannelKindLite.General => (
|
||||
turbineChat.GeneralRoom,
|
||||
(uint)TurbineChat.ChatType.General,
|
||||
"General"),
|
||||
ChatChannelKindLite.Trade => (
|
||||
turbineChat.TradeRoom,
|
||||
(uint)TurbineChat.ChatType.Trade,
|
||||
"Trade"),
|
||||
ChatChannelKindLite.Lfg => (
|
||||
turbineChat.LfgRoom,
|
||||
(uint)TurbineChat.ChatType.Lfg,
|
||||
"LFG"),
|
||||
ChatChannelKindLite.Roleplay => (
|
||||
turbineChat.RoleplayRoom,
|
||||
(uint)TurbineChat.ChatType.Roleplay,
|
||||
"Roleplay"),
|
||||
ChatChannelKindLite.Society => (
|
||||
turbineChat.SocietyRoom,
|
||||
(uint)TurbineChat.ChatType.Society,
|
||||
"Society"),
|
||||
ChatChannelKindLite.Olthoi => (
|
||||
turbineChat.OlthoiRoom,
|
||||
(uint)TurbineChat.ChatType.Olthoi,
|
||||
"Olthoi"),
|
||||
_ => (0u, 0u, string.Empty),
|
||||
};
|
||||
|
||||
if (!turbineChat.Enabled || room == 0u)
|
||||
{
|
||||
return new TurbineChatGateResult(
|
||||
TurbineChatGateStatus.Unavailable, room, chatType, name);
|
||||
}
|
||||
|
||||
bool hearOption = kind switch
|
||||
{
|
||||
ChatChannelKindLite.Allegiance =>
|
||||
(options.Options1
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.General =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Trade =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Lfg =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Roleplay =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)
|
||||
!= 0u,
|
||||
ChatChannelKindLite.Society =>
|
||||
(options.Options2
|
||||
& (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)
|
||||
!= 0u,
|
||||
// Retail's Olthoi entry point passes CPlayerSystem::IsOlthoi()
|
||||
// as its hearOption argument instead of a PlayerModule flag —
|
||||
// there is no "Hear Olthoi chat" toggle, only heritage.
|
||||
ChatChannelKindLite.Olthoi => isOlthoiPlayer,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
return hearOption
|
||||
? new TurbineChatGateResult(TurbineChatGateStatus.Allowed, room, chatType, name)
|
||||
: new TurbineChatGateResult(TurbineChatGateStatus.NotListening, room, chatType, name);
|
||||
}
|
||||
}
|
||||
|
|
@ -649,15 +649,16 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
command.StatId);
|
||||
}
|
||||
|
||||
public RuntimeCommandResult SetOptions1(
|
||||
public RuntimeCommandResult SetSingleOption(
|
||||
RuntimeGenerationToken expectedGeneration,
|
||||
uint options)
|
||||
uint optionId,
|
||||
bool value)
|
||||
{
|
||||
RuntimeCommandStatus gate =
|
||||
Validate(expectedGeneration, out WorldSession? session);
|
||||
if (gate != RuntimeCommandStatus.Accepted)
|
||||
return Result(gate);
|
||||
session!.SendSetCharacterOptions(options);
|
||||
session!.SendSetSingleCharacterOption(optionId, value);
|
||||
return EmitResult(
|
||||
RuntimeCommandDomain.Character,
|
||||
operation: 4,
|
||||
|
|
@ -951,31 +952,49 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
RuntimeChatChannel channel,
|
||||
string text)
|
||||
{
|
||||
if (TryMapTurbine(
|
||||
channel,
|
||||
out ChatChannelKindLite turbineKind,
|
||||
out TurbineChat.ChatType chatType))
|
||||
if (TryMapTurbine(channel, out ChatChannelKindLite turbineKind))
|
||||
{
|
||||
TurbineChatState state =
|
||||
_runtime.CommunicationOwner.TurbineChat;
|
||||
uint roomId = state.RoomFor(turbineKind);
|
||||
if (state.Enabled && roomId != 0u)
|
||||
// CH3 (2026-08-09): the SAME membership gate the graphical host
|
||||
// uses (LiveSessionCommandRouter.RouteTurbineChat) — retail
|
||||
// SendTurbineChat @0x0057db10 never falls through to the legacy
|
||||
// ChatChannel bitflag; it refuses locally instead.
|
||||
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
|
||||
turbineKind,
|
||||
_runtime.CommunicationOwner.TurbineChat,
|
||||
_runtime.CharacterOwner.Options,
|
||||
_runtime.CharacterOwner.IsOlthoiPlayer);
|
||||
|
||||
switch (gate.Status)
|
||||
{
|
||||
session.SendTurbineChatTo(
|
||||
roomId,
|
||||
(uint)chatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
_runtime.PlayerIdentity.ServerGuid,
|
||||
text,
|
||||
state.NextContextId());
|
||||
return true;
|
||||
case TurbineChatGateStatus.Unavailable:
|
||||
_runtime.CommunicationOwner.AddText(
|
||||
ClientTextRefusals.TurbineChatUnavailable,
|
||||
RetailLogTextType.Default);
|
||||
return true;
|
||||
case TurbineChatGateStatus.NotListening:
|
||||
{
|
||||
(string? refusal, RetailLogTextType type) =
|
||||
WeenieErrorMessages.Resolve(0x0551u, gate.DisplayName);
|
||||
if (refusal is not null)
|
||||
_runtime.CommunicationOwner.AddText(refusal, type);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
session.SendTurbineChatTo(
|
||||
gate.RoomId,
|
||||
gate.ChatType,
|
||||
(uint)TurbineChat.DispatchType.SendToRoomById,
|
||||
_runtime.PlayerIdentity.ServerGuid,
|
||||
text,
|
||||
_runtime.CommunicationOwner.TurbineChat.NextContextId());
|
||||
return true;
|
||||
}
|
||||
|
||||
uint? legacyChannel = channel switch
|
||||
{
|
||||
RuntimeChatChannel.Fellowship => 0x00000800u,
|
||||
RuntimeChatChannel.Allegiance => 0x02000000u,
|
||||
RuntimeChatChannel.AllegianceBroadcast => 0x02000000u,
|
||||
RuntimeChatChannel.Vassals => 0x00001000u,
|
||||
RuntimeChatChannel.Patron => 0x00002000u,
|
||||
RuntimeChatChannel.Monarch => 0x00004000u,
|
||||
|
|
@ -990,32 +1009,17 @@ public sealed class DirectGameRuntimeCommandAdapter
|
|||
|
||||
private static bool TryMapTurbine(
|
||||
RuntimeChatChannel channel,
|
||||
out ChatChannelKindLite kind,
|
||||
out TurbineChat.ChatType chatType)
|
||||
out ChatChannelKindLite kind)
|
||||
{
|
||||
(kind, chatType) = channel switch
|
||||
kind = channel switch
|
||||
{
|
||||
RuntimeChatChannel.Allegiance => (
|
||||
ChatChannelKindLite.Allegiance,
|
||||
TurbineChat.ChatType.Allegiance),
|
||||
RuntimeChatChannel.General => (
|
||||
ChatChannelKindLite.General,
|
||||
TurbineChat.ChatType.General),
|
||||
RuntimeChatChannel.Trade => (
|
||||
ChatChannelKindLite.Trade,
|
||||
TurbineChat.ChatType.Trade),
|
||||
RuntimeChatChannel.LookingForGroup => (
|
||||
ChatChannelKindLite.Lfg,
|
||||
TurbineChat.ChatType.Lfg),
|
||||
RuntimeChatChannel.Roleplay => (
|
||||
ChatChannelKindLite.Roleplay,
|
||||
TurbineChat.ChatType.Roleplay),
|
||||
RuntimeChatChannel.Society => (
|
||||
ChatChannelKindLite.Society,
|
||||
TurbineChat.ChatType.Society),
|
||||
RuntimeChatChannel.Olthoi => (
|
||||
ChatChannelKindLite.Olthoi,
|
||||
TurbineChat.ChatType.Olthoi),
|
||||
RuntimeChatChannel.Allegiance => ChatChannelKindLite.Allegiance,
|
||||
RuntimeChatChannel.General => ChatChannelKindLite.General,
|
||||
RuntimeChatChannel.Trade => ChatChannelKindLite.Trade,
|
||||
RuntimeChatChannel.LookingForGroup => ChatChannelKindLite.Lfg,
|
||||
RuntimeChatChannel.Roleplay => ChatChannelKindLite.Roleplay,
|
||||
RuntimeChatChannel.Society => ChatChannelKindLite.Society,
|
||||
RuntimeChatChannel.Olthoi => ChatChannelKindLite.Olthoi,
|
||||
_ => default,
|
||||
};
|
||||
return channel is RuntimeChatChannel.Allegiance
|
||||
|
|
|
|||
|
|
@ -57,7 +57,15 @@ public sealed record LiveCharacterSessionBindings(
|
|||
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
|
||||
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
|
||||
// unchanged.
|
||||
Action? OnMovementStatsUpdated = null);
|
||||
Action? OnMovementStatsUpdated = null,
|
||||
// Campaign CH slice CH3 (2026-08-09): fires with the raw
|
||||
// (options1, options2) pair whenever a fresh PlayerDescription lands —
|
||||
// AFTER Character.Options.Replace has already committed them. Lets the
|
||||
// graphical host reseed its Settings "Hear * Chat" draft from server
|
||||
// truth (research doc §5.2/§6.4: the local ChatSettings.Default lies
|
||||
// relative to ACE's CharacterOptions2.Default). Optional/nullable so
|
||||
// every existing caller compiles unchanged.
|
||||
Action<uint, uint>? OnCharacterOptionsChanged = null);
|
||||
|
||||
public sealed record LiveSocialSessionBindings(
|
||||
ChatLog Chat,
|
||||
|
|
@ -197,7 +205,11 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
friends: social.Friends,
|
||||
squelch: social.Squelch,
|
||||
onDesiredComponents: null,
|
||||
onCharacterOptions: character.Character.Options.Replace,
|
||||
onCharacterOptions: (options1, options2) =>
|
||||
{
|
||||
character.Character.Options.Replace(options1, options2);
|
||||
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
|
||||
},
|
||||
clientTime: character.ClientTime,
|
||||
externalContainers: inventory.ExternalContainers,
|
||||
vendor: inventory.Vendor,
|
||||
|
|
@ -501,19 +513,40 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
|
||||
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
|
||||
{
|
||||
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
|
||||
return;
|
||||
switch (parsed.Body)
|
||||
{
|
||||
case TurbineChat.Payload.EventSendToRoom message:
|
||||
// message.RoomId is an opaque per-session Turbine room GUID,
|
||||
// not a legacy channel bitflag — ChatLog.OnChannelBroadcast's
|
||||
// default (legacy-bit) LogTextType derivation would
|
||||
// misclassify it, so the room's own ChatType maps to
|
||||
// LogTextType explicitly here instead.
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType),
|
||||
channelName: TurbineChatDisplayNames.Resolve(
|
||||
message.RoomId,
|
||||
message.ChatType));
|
||||
return;
|
||||
|
||||
// message.RoomId is an opaque per-session Turbine room GUID, not a
|
||||
// legacy channel bitflag — ChatLog.OnChannelBroadcast's default
|
||||
// (legacy-bit) LogTextType derivation would misclassify it, so the
|
||||
// room's own ChatType maps to LogTextType explicitly here instead.
|
||||
chat.OnChannelBroadcast(
|
||||
message.RoomId,
|
||||
message.SenderName,
|
||||
message.Message,
|
||||
logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType),
|
||||
channelName: TurbineChatDisplayNames.Resolve(message.RoomId, message.ChatType));
|
||||
case TurbineChat.Payload.Response { HResult: not 0 } response:
|
||||
// CH3 (2026-08-09, research doc §2.7/§6.6): previously
|
||||
// discarded unconditionally — a server-side send rejection
|
||||
// was completely invisible. HResult==0 (the overwhelmingly
|
||||
// common case) stays silent, matching retail's own quiet
|
||||
// success ack.
|
||||
chat.OnSystemMessage(
|
||||
"TurbineChat send rejected "
|
||||
+ $"(hresult=0x{unchecked((uint)response.HResult):X8}).",
|
||||
(uint)RetailLogTextType.Default);
|
||||
return;
|
||||
|
||||
default:
|
||||
// Response with HResult==0, or Unknown — nothing to surface.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Validate(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ public static class ChannelResolver
|
|||
// CoVassals = 0x01000000
|
||||
// AllegianceBroadcast = 0x02000000
|
||||
ChatChannelKind.Fellowship => new Resolved(0x00000800u, "Fellowship"),
|
||||
ChatChannelKind.Allegiance => new Resolved(0x02000000u, "Allegiance"),
|
||||
// CH3 (2026-08-09): Allegiance itself is ALWAYS Turbine now — see
|
||||
// ChatChannelKind.AllegianceBroadcast's doc comment. This resolver
|
||||
// handles only the legacy pipeline, so the id 0x02000000 moved to
|
||||
// that dedicated kind (retail's @ab verb).
|
||||
ChatChannelKind.AllegianceBroadcast => new Resolved(0x02000000u, "Allegiance"),
|
||||
ChatChannelKind.Vassals => new Resolved(0x00001000u, "Vassals"),
|
||||
ChatChannelKind.Patron => new Resolved(0x00002000u, "Patron"),
|
||||
ChatChannelKind.Monarch => new Resolved(0x00004000u, "Monarch"),
|
||||
|
|
|
|||
|
|
@ -11,10 +11,17 @@ namespace AcDream.UI.Abstractions;
|
|||
/// <para>
|
||||
/// Channels split into:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Legacy</b> (Fellowship..CoVassals): map to a fixed <c>ChatChannel</c>
|
||||
/// bitflag id via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
|
||||
/// <item><b>Turbine</b> (General..Olthoi): require a TurbineChat channel id
|
||||
/// resolved at runtime — not yet wired (Phase I.6 owns TurbineChat).</item>
|
||||
/// <item><b>Legacy</b> (Fellowship, Vassals, Patron, Monarch, CoVassals,
|
||||
/// AllegianceBroadcast): map to a fixed <c>ChatChannel</c> bitflag id
|
||||
/// via <see cref="ChannelResolver"/> and ride 0x0147 ChatChannel.</item>
|
||||
/// <item><b>Turbine</b> (Allegiance, General, Trade, Lfg, Roleplay, Society,
|
||||
/// Olthoi): resolve to a server-assigned Turbine room id at runtime via
|
||||
/// <c>TurbineChatMembershipGate</c> and ride 0xF7DE TurbineChat.
|
||||
/// Campaign CH slice CH3 (2026-08-09) moved <see cref="Allegiance"/>
|
||||
/// here unconditionally — retail's <c>@a</c> binds to
|
||||
/// <c>DoTurbineChat_Allegiance</c>, never the legacy bitflag; see
|
||||
/// <see cref="AllegianceBroadcast"/> for the legacy sibling
|
||||
/// (retail's <c>@ab</c>).</item>
|
||||
/// <item><b>Say</b> / <b>Tell</b>: route to the dedicated 0x0015 / 0x005D
|
||||
/// opcodes — no channel id needed.</item>
|
||||
/// </list>
|
||||
|
|
@ -36,5 +43,17 @@ public enum ChatChannelKind
|
|||
Roleplay,
|
||||
Society,
|
||||
Olthoi,
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH3 (2026-08-09): the legacy
|
||||
/// <c>ChatChannel.AllegianceBroadcast (0x02000000)</c> bound to retail's
|
||||
/// <c>@ab</c> verb — a monarch/speaker-permission broadcast over the
|
||||
/// legacy 0x0147 pipe. Distinct from <see cref="Allegiance"/>, which is
|
||||
/// ALWAYS the Turbine room now: retail's <c>@a</c> binds unconditionally
|
||||
/// to <c>DoTurbineChat_Allegiance</c> once Turbine chat starts (research
|
||||
/// doc §4.3), so it never falls back to this bitflag.
|
||||
/// </summary>
|
||||
AllegianceBroadcast,
|
||||
|
||||
Unknown,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ public static class ChatInputParser
|
|||
("/fellowship", ChatChannelKind.Fellowship),
|
||||
("/a", ChatChannelKind.Allegiance),
|
||||
("/allegiance", ChatChannelKind.Allegiance),
|
||||
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
|
||||
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
|
||||
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
|
||||
// was deliberately NOT added: it is not a registered retail verb
|
||||
// (only "ab" is), and CH3's scope is "cross-check the registry;
|
||||
// do not add verbs it doesn't have."
|
||||
("/ab", ChatChannelKind.AllegianceBroadcast),
|
||||
("/m", ChatChannelKind.Monarch),
|
||||
("/monarch", ChatChannelKind.Monarch),
|
||||
("/p", ChatChannelKind.Patron),
|
||||
|
|
|
|||
|
|
@ -9,12 +9,17 @@ namespace AcDream.UI.Abstractions.Panels.Settings;
|
|||
/// retail bit values.
|
||||
///
|
||||
/// <para>
|
||||
/// L.0 scope: <b>local-only</b> like the rest of L.0. The Hear*Chat
|
||||
/// flags affect client-side <i>display</i> filtering of the existing
|
||||
/// channels — the server still streams every line; the client decides
|
||||
/// what to render. Server-sync arrives in a later phase that flips the
|
||||
/// retail-faithful "tell server which channels I'm subscribed to"
|
||||
/// switch.
|
||||
/// <b>Campaign CH slice CH3 (2026-08-09):</b> these five Hear*Chat flags
|
||||
/// are no longer local-only display filters — they ARE retail's server-side
|
||||
/// Turbine room membership. The App host seeds this draft from
|
||||
/// <c>RuntimeCharacterOptionsState.Options2</c> (already parsed out of
|
||||
/// PlayerDescription) whenever a fresh description lands, and Save publishes
|
||||
/// any changed bit through <c>SetSingleCharacterOption (0x0005)</c> —
|
||||
/// <c>RuntimeSettingsController.SyncChatFromServerOptions</c> /
|
||||
/// <c>SaveChat</c>. There is no sixth toggle for
|
||||
/// <c>HearAllegianceChat (CharacterOptions1 0x40000000)</c>: retail's own
|
||||
/// Settings UI does not expose one either — Allegiance chat membership rides
|
||||
/// allegiance membership, not a standalone preference.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record ChatSettings(
|
||||
|
|
|
|||
|
|
@ -215,6 +215,22 @@ public sealed class SettingsVM
|
|||
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply one externally learned Chat change (server truth — CH3,
|
||||
/// 2026-08-09) to both snapshots, matching
|
||||
/// <see cref="ApplyExternalGameplayChange"/>'s shape. Existing unsaved
|
||||
/// edits to unrelated fields remain drafts rather than being accidentally
|
||||
/// promoted to persisted state.
|
||||
/// </summary>
|
||||
public void ApplyExternalChatChange(Func<ChatSettings, ChatSettings> update)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
_chatPersisted = update(_chatPersisted)
|
||||
?? throw new InvalidOperationException("Chat update returned null.");
|
||||
_chatDraft = update(_chatDraft)
|
||||
?? throw new InvalidOperationException("Chat update returned null.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Character draft with <paramref name="value"/>.
|
||||
/// Per-toon — the host knows which toon's bag we're editing because
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue