acdream/src/AcDream.App/Net/LiveSessionCommandRouter.cs
Erik 172c6f9aa3 feat(chat): Campaign CH slice CH1 — retail LogTextType color table
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.

Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.

Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.

Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.

Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:24:09 +02:00

436 lines
18 KiB
C#

using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
internal sealed record LiveSessionCommandBindings(
ClientCommandController.Bindings ClientCommands,
ChatLog Chat,
TurbineChatState TurbineChat,
Func<uint> PlayerGuid,
Action<string> SendTalk,
Action<string, string> SendTell,
Action<uint, string> SendChannel,
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
Action<ShortcutEntry> AddShortcut,
Action<uint> RemoveShortcut,
Action<uint, int, int> AddFavorite,
Action<uint, int> RemoveFavorite,
Action<uint> SetSpellbookFilter,
Action<uint> ForgetSpell,
Action<uint, uint> SetDesiredComponent,
Action ClearDesiredComponents,
Action<uint, ulong> RaiseAttribute,
Action<uint, ulong> RaiseVital,
Action<uint, ulong> RaiseSkill,
Action<uint, uint> TrainSkill,
Action<uint> SetCharacterOptions,
Action<string> AddFriend,
Action<uint> RemoveFriend,
Action ClearFriends,
Action RequestLegacyFriends,
Action<bool, uint, string, uint> ModifyCharacterSquelch,
Action<bool, string> ModifyAccountSquelch,
Action<bool, uint> ModifyGlobalSquelch,
Action<string>? 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 SetCharacterOptionsRuntimeCmd(uint Options);
internal readonly record struct AddFriendRuntimeCmd(string Name);
internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId);
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);
/// <summary>
/// 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.
/// </summary>
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);
_clientCommands = bindings.ClientCommands;
var commands = new LiveCommandBus();
var clientCommands = new ClientCommandController(
BuildGuardedClientCommands(bindings.ClientCommands));
commands.Register<ExecuteClientCommandCmd>(clientCommands.Execute);
commands.Register<SendServerCommandCmd>(command =>
{
if (!string.IsNullOrEmpty(command.Text))
SendIfActive(() => bindings.SendTalk(command.Text));
});
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
commands.Register<AddShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
commands.Register<RemoveShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.RemoveShortcut(command.Index)));
commands.Register<AddFavoriteRuntimeCmd>(
command => SendIfActive(() => bindings.AddFavorite(
command.SpellId,
command.Position,
command.TabIndex)));
commands.Register<RemoveFavoriteRuntimeCmd>(
command => SendIfActive(() => bindings.RemoveFavorite(
command.SpellId,
command.TabIndex)));
commands.Register<SetSpellbookFilterRuntimeCmd>(
command => SendIfActive(() =>
bindings.SetSpellbookFilter(command.Filters)));
commands.Register<ForgetSpellRuntimeCmd>(
command => SendIfActive(() => bindings.ForgetSpell(command.SpellId)));
commands.Register<SetDesiredComponentRuntimeCmd>(
command => SendIfActive(() => bindings.SetDesiredComponent(
command.ComponentId,
command.Amount)));
commands.Register<ClearDesiredComponentsRuntimeCmd>(
_ => SendIfActive(bindings.ClearDesiredComponents));
commands.Register<RaiseAttributeRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseAttribute(command.StatId, command.Cost)));
commands.Register<RaiseVitalRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseVital(command.StatId, command.Cost)));
commands.Register<RaiseSkillRuntimeCmd>(
command => SendIfActive(() =>
bindings.RaiseSkill(command.StatId, command.Cost)));
commands.Register<TrainSkillRuntimeCmd>(
command => SendIfActive(() =>
bindings.TrainSkill(command.StatId, command.Cost)));
commands.Register<SetCharacterOptionsRuntimeCmd>(
command => SendIfActive(() =>
bindings.SetCharacterOptions(command.Options)));
commands.Register<AddFriendRuntimeCmd>(
command => SendIfActive(() => bindings.AddFriend(command.Name)));
commands.Register<RemoveFriendRuntimeCmd>(
command => SendIfActive(() =>
bindings.RemoveFriend(command.CharacterId)));
commands.Register<ClearFriendsRuntimeCmd>(
_ => SendIfActive(bindings.ClearFriends));
commands.Register<RequestLegacyFriendsRuntimeCmd>(
_ => SendIfActive(bindings.RequestLegacyFriends));
commands.Register<ModifyCharacterSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyCharacterSquelch(
command.Add,
command.CharacterId,
command.Name,
command.MessageType)));
commands.Register<ModifyAccountSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyAccountSquelch(
command.Add,
command.Name)));
commands.Register<ModifyGlobalSquelchRuntimeCmd>(
command => SendIfActive(() => bindings.ModifyGlobalSquelch(
command.Add,
command.MessageType)));
_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>(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();
}
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,
targetOrChannel: command.TargetName,
// 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: 0x04u);
return;
}
TurbineResolution? turbine = TurbineChatRouting.Resolve(
command.Channel,
bindings.TurbineChat);
if (turbine is not null)
{
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));
return;
}
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel);
if (legacy is null)
{
bindings.Log?.Invoke(
$"chat: SendChatCmd kind={command.Channel} dropped " +
$"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)");
return;
}
bindings.Log?.Invoke(
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
$"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}");
if (!SendIfActive(() =>
bindings.SendChannel(legacy.Value.ChannelId, command.Text)))
return;
bindings.Chat.OnSelfSent(
ChatKind.Channel,
command.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 catch-all stays 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()));
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
{
lock (_gate)
{
ClientCommandController.Bindings? bindings = _clientCommands;
if (_state != 1 || bindings is null)
return false;
invoke(bindings);
return true;
}
}
private TResult ReadClient<TResult>(
Func<ClientCommandController.Bindings, TResult> 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;
}
}
}
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);
}
}