refactor(net): own live session routing

This commit is contained in:
Erik 2026-07-21 11:17:09 +02:00
parent 707e606e35
commit 961bdd07b7
12 changed files with 1645 additions and 543 deletions

View file

@ -0,0 +1,326 @@
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
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<string>? Log = null);
/// <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 : ICommandBus, IDisposable
{
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 = 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);
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);
}
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)));
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);
}
/// <summary>
/// Derive the retail-facing channel label from the inbound Turbine chat
/// type, retaining the numeric-room fallback for unknown extensions.
/// </summary>
public static string DisplayName(uint roomId, uint chatType) =>
(TurbineChat.ChatType)chatType switch
{
TurbineChat.ChatType.Allegiance => "Allegiance",
TurbineChat.ChatType.General => "General",
TurbineChat.ChatType.Trade => "Trade",
TurbineChat.ChatType.Lfg => "LFG",
TurbineChat.ChatType.Roleplay => "Roleplay",
TurbineChat.ChatType.Society => "Society",
TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
TurbineChat.ChatType.Olthoi => "Olthoi",
_ => $"Room 0x{roomId:X8}",
};
}

View file

@ -0,0 +1,341 @@
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
namespace AcDream.App.Net;
internal sealed record LiveEntitySessionSink(
Action<WorldSession.EntitySpawn> Spawned,
Action<DeleteObject.Parsed> Deleted,
Action<PickupEvent.Parsed> PickedUp,
Action<WorldSession.EntityMotionUpdate> MotionUpdated,
Action<WorldSession.EntityPositionUpdate> PositionUpdated,
Action<VectorUpdate.Parsed> VectorUpdated,
Action<SetState.Parsed> StateUpdated,
Action<ParentEvent.Parsed> ParentUpdated,
Action<uint> TeleportStarted,
Action<ObjDescEvent.Parsed> AppearanceUpdated,
Action<PlayPhysicsScript> PlayPhysicsScript,
Action<PlayPhysicsScriptType> PlayPhysicsScriptType);
internal sealed record LiveEnvironmentSessionSink(
Action<uint> EnvironChanged,
Action<double> ServerTimeUpdated);
internal sealed record LiveInventorySessionBindings(
ClientObjectTable Objects,
LocalPlayerState LocalPlayer,
Func<uint> PlayerGuid,
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
Action<uint>? OnUseDone,
ItemManaState? ItemMana,
Action<IReadOnlyList<(uint Id, uint Amount)>>? OnDesiredComponents,
ExternalContainerState? ExternalContainers);
internal sealed record LiveCharacterSessionBindings(
CombatState Combat,
Spellbook Spellbook,
Func<uint, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Action<uint, uint>? OnCharacterOptions,
Func<double>? ClientTime);
internal sealed record LiveSocialSessionBindings(
ChatLog Chat,
TurbineChatState TurbineChat,
FriendsState? Friends,
SquelchState? Squelch);
/// <summary>
/// Owns every inbound subscription for one exact live session. Domain state
/// remains in the supplied sinks; this class owns only routing and teardown.
/// </summary>
internal sealed class LiveSessionEventRouter : IDisposable
{
private readonly LiveSessionSubscriptionSet _subscriptions = new();
private readonly Action<int>? _constructionCheckpoint;
private int _constructionStep;
private int _accepting = 1;
public LiveSessionEventRouter(
WorldSession session,
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social,
Action<int>? constructionCheckpoint = null)
{
ArgumentNullException.ThrowIfNull(session);
Validate(entities, environment, inventory, character, social);
_constructionCheckpoint = constructionCheckpoint;
try
{
// Preserve the shipped pre-Connect registration order. Property
// state is installed before lifecycle packets can be dispatched.
_subscriptions.Add(ObjectTableWiring.Wire(
session,
inventory.Objects,
inventory.PlayerGuid,
inventory.LocalPlayer,
IsAccepting));
ConstructionCheckpoint();
_subscriptions.Add(CombatStateWiring.Wire(
session,
character.Combat,
IsAccepting));
ConstructionCheckpoint();
Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned);
Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted);
Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp);
Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated);
Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated);
Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated);
Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated);
Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated);
Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted);
Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated);
Subscribe(
h => session.PlayPhysicsScriptReceived += h,
h => session.PlayPhysicsScriptReceived -= h,
entities.PlayPhysicsScript);
Subscribe(
h => session.PlayPhysicsScriptTypeReceived += h,
h => session.PlayPhysicsScriptTypeReceived -= h,
entities.PlayPhysicsScriptType);
Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged);
Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated);
_subscriptions.Add(GameEventWiring.WireAll(
session.GameEvents,
inventory.Objects,
character.Combat,
character.Spellbook,
social.Chat,
inventory.LocalPlayer,
social.TurbineChat,
onSkillsUpdated: character.OnSkillsUpdated,
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
playerGuid: inventory.PlayerGuid,
onUseDone: inventory.OnUseDone,
itemMana: inventory.ItemMana,
onConfirmationRequest: character.OnConfirmationRequest,
onConfirmationDone: character.OnConfirmationDone,
friends: social.Friends,
squelch: social.Squelch,
onDesiredComponents: inventory.OnDesiredComponents,
onCharacterOptions: character.OnCharacterOptions,
clientTime: character.ClientTime,
externalContainers: inventory.ExternalContainers,
accepting: IsAccepting));
ConstructionCheckpoint();
_subscriptions.Add(new CombatChatTranslator(
character.Combat,
social.Chat,
IsAccepting));
ConstructionCheckpoint();
Subscribe<HearSpeech.Parsed>(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech =>
social.Chat.OnLocalSpeech(
speech.SenderName,
speech.Text,
speech.SenderGuid,
speech.IsRanged));
Subscribe<ServerMessage.Parsed>(
h => session.ServerMessageReceived += h,
h => session.ServerMessageReceived -= h,
message => social.Chat.OnSystemMessage(message.Message, message.ChatType));
Subscribe<EmoteText.Parsed>(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote =>
social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<SoulEmote.Parsed>(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote =>
social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid));
Subscribe<PlayerKilled.Parsed>(
h => session.PlayerKilledReceived += h,
h => session.PlayerKilledReceived -= h,
killed => social.Chat.OnPlayerKilled(
killed.DeathMessage,
killed.VictimGuid,
killed.KillerGuid));
Subscribe<TurbineChat.Parsed>(
h => session.TurbineChatReceived += h,
h => session.TurbineChatReceived -= h,
parsed => RouteTurbineChat(social.Chat, parsed));
Subscribe<PrivateUpdateVital.ParsedFull>(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital =>
inventory.LocalPlayer.OnVitalUpdate(
vital.VitalId,
vital.Ranks,
vital.Start,
vital.Xp,
vital.Current));
Subscribe<PrivateUpdateVital.ParsedCurrent>(
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
}
catch (Exception constructionError)
{
Interlocked.Exchange(ref _accepting, 0);
try
{
_subscriptions.Dispose();
}
catch (Exception cleanupError)
{
throw new AggregateException(
"live-session event routing failed and cleanup also failed",
constructionError,
cleanupError);
}
throw;
}
}
public bool Accepting => IsAccepting();
public void Dispose()
{
Interlocked.Exchange(ref _accepting, 0);
_subscriptions.Dispose();
}
private void Subscribe<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action<T> sink)
{
Action<T> handler = value =>
{
if (Volatile.Read(ref _accepting) != 0)
sink(value);
};
attach(handler);
try
{
_subscriptions.Add(() => detach(handler));
}
catch
{
detach(handler);
throw;
}
ConstructionCheckpoint();
}
private void ConstructionCheckpoint() =>
_constructionCheckpoint?.Invoke(++_constructionStep);
private bool IsAccepting() => Volatile.Read(ref _accepting) != 0;
private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed)
{
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
return;
chat.OnChannelBroadcast(
message.RoomId,
message.SenderName,
message.Message,
TurbineChatRouting.DisplayName(message.RoomId, message.ChatType));
}
private static void Validate(
LiveEntitySessionSink entities,
LiveEnvironmentSessionSink environment,
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character,
LiveSocialSessionBindings social)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(social);
ArgumentNullException.ThrowIfNull(entities.Spawned);
ArgumentNullException.ThrowIfNull(entities.Deleted);
ArgumentNullException.ThrowIfNull(entities.PickedUp);
ArgumentNullException.ThrowIfNull(entities.MotionUpdated);
ArgumentNullException.ThrowIfNull(entities.PositionUpdated);
ArgumentNullException.ThrowIfNull(entities.VectorUpdated);
ArgumentNullException.ThrowIfNull(entities.StateUpdated);
ArgumentNullException.ThrowIfNull(entities.ParentUpdated);
ArgumentNullException.ThrowIfNull(entities.TeleportStarted);
ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript);
ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType);
ArgumentNullException.ThrowIfNull(environment.EnvironChanged);
ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated);
ArgumentNullException.ThrowIfNull(inventory.Objects);
ArgumentNullException.ThrowIfNull(inventory.LocalPlayer);
ArgumentNullException.ThrowIfNull(inventory.PlayerGuid);
ArgumentNullException.ThrowIfNull(character.Combat);
ArgumentNullException.ThrowIfNull(character.Spellbook);
ArgumentNullException.ThrowIfNull(social.Chat);
ArgumentNullException.ThrowIfNull(social.TurbineChat);
}
}
internal sealed class LiveSessionSubscriptionSet : IDisposable
{
private List<IDisposable>? _subscriptions = [];
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
List<IDisposable>? subscriptions = _subscriptions;
if (subscriptions is null)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
subscriptions.Add(subscription);
}
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
public void Dispose()
{
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
if (subscriptions is null)
return;
List<Exception>? errors = null;
for (int index = subscriptions.Count - 1; index >= 0; index--)
{
try
{
subscriptions[index].Dispose();
}
catch (Exception error)
{
(errors ??= []).Add(error);
}
}
if (errors is not null)
throw new AggregateException(
"one or more live-session subscriptions failed to detach",
errors);
}
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
}
}

View file

@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
// _liveSession + Chat). Null when offline; PanelContext then falls
// back to NullCommandBus.Instance.
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
// Phase I.7 — bridges CombatState's typed events into ChatLog as
// retail-faithful "You hit ..." / "... evaded your attack." lines.
// Disposable; lives for the duration of the live session.
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
// Slice 3: exact session-generation owners. The command router is itself
// an ICommandBus and becomes inert before the displaced socket closes.
private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents;
private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
() => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
if (_liveSession is null)
{
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController.Dispose();
_liveSessionController = null;
}
return;
}
@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
out AcDream.Core.Net.Messages.CharacterList.Selection selection))
{
Console.WriteLine("live: no available characters on account; disconnecting");
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
return;
}
@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
Combat.Clear();
_liveSession.EnterWorld(characterIndex: selection.ActiveIndex);
_liveSessionCommands?.Activate();
_activeToonKey = chosen.Name;
_retailUiRuntime?.RestoreLayout();
@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
catch (Exception ex)
{
Console.WriteLine($"live: session failed: {ex.Message}");
_commandBus = null;
_combatChatTranslator?.Dispose();
_combatChatTranslator = null;
_liveSessionController?.Dispose();
_liveSessionController = null;
_liveSession = null;
try
{
DisposeLiveSessionRouting();
}
finally
{
_liveSessionController?.Dispose();
_liveSessionController = null;
_liveSession = null;
}
ClearInboundEntityState();
}
}
@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
}
/// <summary>
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
/// is constructed and BEFORE any network I/O.
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
{
_liveSession = session;
// D.5.4: wire non-lifecycle object quality/property updates. The live
// handlers below apply Create/Delete to both projections only after
// their canonical timestamp decision.
AcDream.Core.Net.ObjectTableWiring.Wire(
session, Objects, () => _playerServerGuid, LocalPlayer);
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
_liveSession.EntitySpawned += spawn =>
_inboundEntityEvents.Run(
this,
spawn,
static (window, value) => window.OnLiveEntitySpawned(value));
_liveSession.EntityDeleted += deletion =>
_inboundEntityEvents.Run(
this,
deletion,
static (window, value) => window.OnLiveEntityDeleted(value));
_liveSession.EntityPickedUp += pickup =>
_inboundEntityEvents.Run(
this,
pickup,
static (window, value) => window.OnLiveEntityPickedUp(value));
_liveSession.MotionUpdated += motion =>
_inboundEntityEvents.Run(
this,
motion,
static (window, value) => window.OnLiveMotionUpdated(value));
_liveSession.PositionUpdated += position =>
_inboundEntityEvents.Run(
this,
position,
static (window, value) => window.OnLivePositionUpdated(value));
_liveSession.VectorUpdated += vector =>
_inboundEntityEvents.Run(
this,
vector,
static (window, value) => window.OnLiveVectorUpdated(value));
_liveSession.StateUpdated += state =>
_inboundEntityEvents.Run(
this,
state,
static (window, value) => window.OnLiveStateUpdated(value));
_liveSession.ParentUpdated += parent =>
_inboundEntityEvents.Run(
this,
parent,
static (window, value) => window.OnLiveParentUpdated(value));
_liveSession.TeleportStarted += teleport =>
_inboundEntityEvents.Run(
this,
teleport,
static (window, value) => window.OnTeleportStarted(value));
_liveSession.AppearanceUpdated += appearance =>
_inboundEntityEvents.Run(
this,
appearance,
static (window, value) => window.OnLiveAppearanceUpdated(value));
if (_liveSessionEvents is not null || _liveSessionCommands is not null)
throw new InvalidOperationException("live-session routing is already bound");
// Phase 6c — PlayScript (0xF754) arrives from the server as
// a (guid, scriptId) pair. Resolve the guid's current world
// position and feed the PhysicsScript runner; it schedules
// the script's hooks (particle spawns, sound cues, light
// toggles) at their StartTime offsets. This is the channel
// retail uses for spell casts, combat flinches, emote
// gestures, AND — per Agent #5 research — lightning
// flashes during stormy weather.
_liveSession.PlayPhysicsScriptReceived += script =>
_inboundEntityEvents.Run(
this,
script,
static (window, value) => window.OnPlayScriptReceived(value));
_liveSession.PlayPhysicsScriptTypeReceived += message =>
_inboundEntityEvents.Run(
this,
message,
static (window, value) => window._entityEffects?.HandleTyped(value));
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats,
Console.WriteLine);
}
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
// sound types (0x65..0x7B) play a one-shot audio cue.
// Lightning flashes arrive as a PAIRED PlayScript (the
// visual) + AdminEnvirons ThunderXSound (the audio) — both
// are handled here and in OnPlayScriptReceived respectively.
_liveSession.EnvironChanged += OnEnvironChanged;
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
// Phase 3a: also re-roll the active DayGroup if the Dereth-day
// index changed — retail rolls one weather preset per server
// day (r12 §11), deterministic from the day index so retail
// and acdream converge without a wire message.
_liveSession.ServerTimeUpdated += ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
};
// Phase F.1-H.1: wire every parsed GameEvent into the right
// Core state class (chat, combat, spellbook, items). After
// this one call, server-sent ChannelBroadcast / damage
// notifications / spell learns / wield events all update
// the corresponding client-side state without further glue.
// K-fix13 (2026-04-26): cache portal.dat's SkillTable so the
// skill-formula resolver can apply the AttributeFormula
// contribution. Without this, our wire-derived "skill"
// value was missing the dominant attribute-derived term
// and jumps undershot retail by ~30 % at typical
// attribute levels.
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats, Console.WriteLine);
}
AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
TurbineChat,
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
{
// ACE GetFormula (AttributeFormula.cs:55-): when
// formula.X (Attribute1Multiplier) is 0, the formula
// is "no attribute contribution" and the function
// returns 0. Otherwise:
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
if (skillTable?.Skills is null) return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId, out var skillBase))
return 0u;
var f = skillBase.Formula;
if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u;
attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1);
attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2);
long num = (long)a1 * f.Attribute1Multiplier
+ (long)a2 * f.Attribute2Multiplier;
long bonus = num / f.Divisor + f.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
onSkillsUpdated: (runSkill, jumpSkill) =>
{
// K-fix7 (2026-04-26): cache the latest server-sent
// Run / Jump skill values so the next
// EnterPlayerModeNow can hand them to the new
// PlayerMovementController. Push immediately too,
// so a PD that arrives WHILE player mode is active
// (re-equip / log-in mid-session) updates the live
// controller. -1 from the wiring means "skill not
// present in this PD" — keep the previous cached
// value rather than overwriting with -1.
if (runSkill >= 0) _lastSeenRunSkill = runSkill;
if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill;
if (_playerController is not null
&& _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
CreateLiveEntitySessionSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
_playerController.SetCharacterSkills(
_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
itemMana: ItemMana,
onConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
onConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components,
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
clientTime: ClientTimerNow,
externalContainers: _externalContainers);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
// the unified ChatLog. The translator owns the wording
// (templates ported from holtburger chat.rs:221-308); the
// panel renders combat entries via TextColored.
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
// Phase H.1: feed inbound HearSpeech into the chat log.
_liveSession.SpeechHeard += speech =>
Chat.OnLocalSpeech(
sender: speech.SenderName,
text: speech.Text,
senderGuid: speech.SenderGuid,
isRanged: speech.IsRanged);
// Phase I.6: feed inbound TurbineChat events into the chat log.
// The Response variant is fire-and-forget (server-side ack);
// EventSendToRoom is a real chat message broadcast to a room.
// Phase J: ACE's GameMessageSystemChat (used for the login
// banner "Welcome to Asheron's Call ... type @acehelp" and
// for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage,
// parsed in I.5 but never wired. Surface it as a System
// chat line so the welcome banner appears + future server
// pushes (announcements, command responses) show.
_liveSession.ServerMessageReceived += sm =>
Chat.OnSystemMessage(sm.Message, sm.ChatType);
// Phase I.5 + J: emotes already had ChatLog adapters; wire
// their session events here so they actually reach chat.
_liveSession.EmoteHeard += emote =>
Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid);
_liveSession.SoulEmoteHeard += emote =>
Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid);
_liveSession.PlayerKilledReceived += pk =>
Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid);
_liveSession.TurbineChatReceived += parsed =>
{
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
{
// Pass the friendly channel name out-of-band via
// ChatLog.OnChannelBroadcast's channelName param so
// ChatVM.FormatEntry can render the retail-style
// "[Trade] +Acdream says, \"hello\"" without us
// mangling the payload text.
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
Chat.OnChannelBroadcast(
channelId: ev.RoomId,
sender: ev.SenderName,
text: ev.Message,
channelName: label);
}
// Response (server ack of an outbound RequestSendToRoomById)
// and Unknown payloads are intentionally not surfaced —
// the inbound EventSendToRoom for our own message acts as
// the canonical echo.
};
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
// plus a local echo into ChatLog so the player sees their own
// message immediately. Closes over _liveSession + Chat so this
// wiring only exists for the lifetime of the live session.
// Step 2: capture the non-null `session` parameter rather than
// the nullable _liveSession field. Semantically identical (the
// field WAS set to `session` at the top of WireLiveSessionEvents)
// but the compiler can prove non-null for the lambda body.
var liveSession = session;
var chat = Chat;
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
var turbineChat = TurbineChat;
uint playerGuid = _playerServerGuid;
var clientCommandController = new AcDream.App.UI.ClientCommandController(
new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: liveSession.SendTeleportToLifestone,
TeleportToMarketplace: liveSession.SendTeleportToMarketplace,
TeleportToPkArena: liveSession.SendTeleportToPkArena,
TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena,
TeleportToHouse: liveSession.SendTeleportToHouse,
TeleportToMansion: liveSession.SendTeleportToMansion,
QueryAge: liveSession.SendQueryAge,
QueryBirth: liveSession.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () =>
LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: liveSession.SendSuicide,
ClearChat: _ => chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(liveSession, away),
SetAwayMessage: liveSession.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: liveSession.SendDisplayConsent,
ClearConsent: liveSession.SendClearConsent,
RemoveConsent: liveSession.SendRemoveConsent,
SendEmote: liveSession.SendEmote,
Friends: Friends,
AddFriend: liveSession.SendAddFriend,
RemoveFriend: liveSession.SendRemoveFriend,
ClearFriends: liveSession.SendClearFriends,
RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest,
Squelch: Squelch,
ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch,
ModifyAccountSquelch: liveSession.SendModifyAccountSquelch,
ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
liveSession.SendClearDesiredComponents();
DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }));
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
clientCommandController.Execute);
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
{
if (!string.IsNullOrEmpty(cmd.Text))
liveSession.SendTalk(cmd.Text);
});
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
{
if (string.IsNullOrEmpty(cmd.Text)) return;
switch (cmd.Channel)
{
case AcDream.UI.Abstractions.ChatChannelKind.Say:
// Phase J: drop optimistic /say echo. ACE's
// HandleActionTalk broadcasts a HearSpeech back
// to the sender too, and ChatLog.OnLocalSpeech
// detects own-guid match to render it as
// "You say, ...". Optimistic-echoing here
// doubled the line. ALSO: don't echo "@xxx"
// server-side admin commands — ACE consumes
// them silently and replies via SystemChat.
liveSession.SendTalk(cmd.Text);
break;
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
if (string.IsNullOrEmpty(cmd.TargetName)) return;
liveSession.SendTell(cmd.TargetName, cmd.Text);
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
targetOrChannel: cmd.TargetName);
break;
default:
// Phase I.6: try TurbineChat first for the global
// community channels (General/Trade/LFG/Roleplay/
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
// Allegiance is double-routed: try TurbineChat first
// (when the player has a Turbine allegiance room) and
// fall back to the legacy 0x0147 ChatChannel.
//
// We do NOT optimistic-echo channels: ACE's
// TurbineChatHandler broadcasts EventSendToRoom back
// to the sender too, so we always get the canonical
// echo from the server. Optimistic-echoing here
// double-prints the message (one as "[Trade] hello"
// from us, one as "[Trade] +Acdream says, \"hello\""
// from the server). Trust the server.
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
if (turbine is not null)
{
uint cookie = turbineChat.NextContextId();
// Use the live player guid if it's been captured;
// otherwise 0 (server treats unknown sender_id
// gracefully — the cookie is what we care about).
uint senderGuid = _playerServerGuid != 0u
? _playerServerGuid
: playerGuid;
Console.WriteLine(
$"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={cmd.Text.Length}");
liveSession.SendTurbineChatTo(
roomId: turbine.Value.RoomId,
chatType: turbine.Value.ChatType,
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
senderGuid: senderGuid,
text: cmd.Text,
cookie: cookie);
// No optimistic echo: server EventSendToRoom
// broadcast comes back with sender="+Acdream"
// and is rendered by ChatVM as
// "[Trade] +Acdream says, \"hello\"".
break;
}
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
if (resolved is null)
{
// Diagnostic: the user picked a channel kind that
// (a) isn't a Turbine channel TurbineChatState
// knows about and (b) has no legacy ChatChannel
// mapping. Most common cause: TurbineChat hasn't
// been enabled yet (server didn't send 0x0295)
// and the kind is General/Trade/LFG/etc.
Console.WriteLine(
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
return;
}
Console.WriteLine(
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
// Legacy channels (Fellowship / Allegiance / Patron /
// Monarch / Vassals / CoVassals) — keep the optimistic
// echo because legacy ChatChannel does NOT always
// broadcast back to the sender. ChannelName is the
// friendly display name so ChatVM renders it as
// "[Fellowship] +Acdream says, \"hello\"".
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
targetOrChannel: resolved.Value.DisplayName);
break;
}
});
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
_liveSession.VitalUpdated += v =>
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
_liveSessionEvents = events;
_liveSessionCommands = commands;
}
catch
{
commands?.Dispose();
events?.Dispose();
throw;
}
}
private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
Spawned: spawn => _inboundEntityEvents.Run(
this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)),
Deleted: deletion => _inboundEntityEvents.Run(
this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
this, motion, static (window, value) => window.OnLiveMotionUpdated(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
this, position, static (window, value) => window.OnLivePositionUpdated(value)),
VectorUpdated: vector => _inboundEntityEvents.Run(
this, vector, static (window, value) => window.OnLiveVectorUpdated(value)),
StateUpdated: state => _inboundEntityEvents.Run(
this, state, static (window, value) => window.OnLiveStateUpdated(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
this, parent, static (window, value) => window.OnLiveParentUpdated(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
private AcDream.App.Net.LiveInventorySessionBindings
CreateLiveInventorySessionBindings() => new(
Objects,
LocalPlayer,
PlayerGuid: () => _playerServerGuid,
OnShortcuts: list => Shortcuts = list,
OnUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
ItemMana,
OnDesiredComponents: components => DesiredComponents = components,
ExternalContainers: _externalContainers);
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
{
// ACE AttributeFormula.GetFormula
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
// Attribute1Multiplier == 0 means no attribute contribution.
// Otherwise the retail data formula is
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
// Guard a zero divisor because malformed/custom DAT input must not
// take down the live-session dispatcher.
if (skillTable?.Skills is null)
return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
return 0u;
var formula = skillBase.Formula;
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
return 0u;
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
long numerator =
(long)attribute1 * formula.Attribute1Multiplier +
(long)attribute2 * formula.Attribute2Multiplier;
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
if (runSkill >= 0)
_lastSeenRunSkill = runSkill;
if (jumpSkill >= 0)
_lastSeenJumpSkill = jumpSkill;
if (_playerController is not null
&& _lastSeenRunSkill >= 0
&& _lastSeenJumpSkill >= 0)
{
_playerController.SetCharacterSkills(
_lastSeenRunSkill,
_lastSeenJumpSkill);
Console.WriteLine(
$"player: applied server skills run={_lastSeenRunSkill} " +
$"jump={_lastSeenJumpSkill}");
}
},
OnConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
Chat,
TurbineChat,
Friends,
Squelch);
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
AcDream.Core.Net.WorldSession session) => new(
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
TeleportToPkArena: session.SendTeleportToPkArena,
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
TeleportToHouse: session.SendTeleportToHouse,
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => Chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
Chat,
TurbineChat,
PlayerGuid: () => _playerServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
private void DisposeLiveSessionRouting()
{
AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands;
AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents;
_liveSessionCommands = null;
_liveSessionEvents = null;
try
{
commands?.Dispose();
}
finally
{
events?.Dispose();
}
}
/// <summary>
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
/// mesh refs and register it in IGameState. Called from WorldSession events
/// on the main thread (Tick runs in the Silk.NET Update callback).
/// </summary>
private enum LiveProjectionPurpose
{
LogicalRegistration,
@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// is up so panel-emitted SendChatCmd actually flows server-ward.
// Fall back to NullCommandBus for offline / pre-connect renders.
AcDream.UI.Abstractions.ICommandBus bus =
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
_liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus)
AcDream.UI.Abstractions.NullCommandBus.Instance;
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_magicCatalog = null;
}),
new("streamer", () => _streamer?.Dispose()),
new("combat chat", () => _combatChatTranslator?.Dispose()),
new("live session routing", DisposeLiveSessionRouting),
new("equipped children", () => _equippedChildRenderer?.Dispose()),
new("live session", () =>
{
@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_window = null;
}
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
/// <summary>
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
/// to a runtime Turbine room. Returned by
/// <see cref="ResolveTurbineForKind"/> when the player has access
/// to that Turbine channel; null otherwise.
/// </summary>
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
/// <summary>
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
/// runtime Turbine room id + chat-type. Returns null when
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
/// or the channel has no assigned room (e.g. player not in a society).
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
/// </summary>
private static TurbineResolution? ResolveTurbineForKind(
AcDream.UI.Abstractions.ChatChannelKind kind,
AcDream.Core.Chat.TurbineChatState state)
{
if (!state.Enabled) return null;
var (room, chatType, name) = kind switch
{
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
AcDream.UI.Abstractions.ChatChannelKind.General =>
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
AcDream.UI.Abstractions.ChatChannelKind.Society =>
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
_ => (0u, 0u, string.Empty),
};
if (room == 0u) return null;
return new TurbineResolution(room, chatType, name);
}
/// <summary>
/// Pick a human-readable label for a Turbine room broadcast. Uses
/// the chat-type when known (semantic name), falls back to the
/// numeric room id for unknown rooms.
/// </summary>
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
{
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
{
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
_ => $"Room 0x{roomId:X8}",
};
}
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer

View file

@ -12,13 +12,19 @@ public static class CombatStateWiring
{
public const uint CombatModePropertyId = 40u;
public static IDisposable Wire(WorldSession session, CombatState combat)
public static IDisposable Wire(
WorldSession session,
CombatState combat,
Func<bool>? accepting = null)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(combat);
Action<WorldSession.PlayerIntPropertyUpdate> handler = update =>
{
if (accepting?.Invoke() == false) return;
ApplyPlayerIntProperty(combat, update.Property, update.Value);
};
session.PlayerIntPropertyUpdated += handler;
return new EventSubscription(session, handler);
}

View file

@ -81,7 +81,8 @@ public static class GameEventWiring
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
Func<double>? clientTime = null,
ExternalContainerState? externalContainers = null)
ExternalContainerState? externalContainers = null,
Func<bool>? accepting = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -89,7 +90,7 @@ public static class GameEventWiring
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
var registrar = new OwnedGameEventRegistrar(dispatcher);
var registrar = new OwnedGameEventRegistrar(dispatcher, accepting);
using var construction = new RegistrationBuildScope(registrar);
// ── Chat ──────────────────────────────────────────────────
@ -659,14 +660,23 @@ public static class GameEventWiring
}
private sealed class OwnedGameEventRegistrar(
GameEventDispatcher dispatcher) : IDisposable
GameEventDispatcher dispatcher,
Func<bool>? accepting) : IDisposable
{
private readonly SubscriptionSet _subscriptions = new();
public void Register(
GameEventType type,
GameEventDispatcher.EventHandler handler) =>
_subscriptions.Add(dispatcher.RegisterOwned(type, handler));
GameEventDispatcher.EventHandler handler)
{
GameEventDispatcher.EventHandler registered = accepting is null
? handler
: envelope =>
{
if (accepting()) handler(envelope);
};
_subscriptions.Add(dispatcher.RegisterOwned(type, registered));
}
public void Dispose() => _subscriptions.Dispose();
}

View file

@ -23,7 +23,8 @@ public static class ObjectTableWiring
WorldSession session,
ClientObjectTable table,
Func<uint>? playerGuid = null,
LocalPlayerState? localPlayer = null)
LocalPlayerState? localPlayer = null,
Func<bool>? accepting = null)
{
ArgumentNullException.ThrowIfNull(session);
ArgumentNullException.ThrowIfNull(table);
@ -37,7 +38,10 @@ public static class ObjectTableWiring
// UiEffects — the server is the authority on object properties. UpdateIntProperty
// stores it in the bundle and still mirrors UiEffects → the typed Effects field.
Action<WorldSession.ObjectIntPropertyUpdate> objectIntUpdated = u =>
{
if (accepting?.Invoke() == false) return;
table.UpdateIntProperty(u.Guid, u.Property, u.Value);
};
session.ObjectIntPropertyUpdated += objectIntUpdated;
subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated);
@ -49,6 +53,7 @@ public static class ObjectTableWiring
// than creating a phantom — the next PD / CreateObject seeds it.
Action<WorldSession.PlayerIntPropertyUpdate> playerIntUpdated = u =>
{
if (accepting?.Invoke() == false) return;
if (playerGuid is not null)
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
};
@ -61,20 +66,30 @@ public static class ObjectTableWiring
// Apply the authoritative 0x02CF update to both in this one wiring owner so they
// cannot drift after the login PlayerDescription snapshot.
Action<WorldSession.PlayerInt64PropertyUpdate> playerInt64Updated = u =>
{
if (accepting?.Invoke() == false) return;
ApplyPlayerInt64PropertyUpdate(
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
};
session.PlayerInt64PropertyUpdated += playerInt64Updated;
subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated);
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
Action<WorldSession.StackSizeUpdate> stackSizeUpdated = u =>
{
if (accepting?.Invoke() == false) return;
table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
};
session.StackSizeUpdated += stackSizeUpdated;
subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated);
// B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory
// view; drop it from the table (retail ClientUISystem removes it from maintenance).
Action<uint> inventoryObjectRemoved = guid => table.Remove(guid);
Action<uint> inventoryObjectRemoved = guid =>
{
if (accepting?.Invoke() == false) return;
table.Remove(guid);
};
session.InventoryObjectRemoved += inventoryObjectRemoved;
subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved);

View file

@ -51,16 +51,34 @@ public sealed class CombatChatTranslator : IDisposable
private bool _disposed;
public CombatChatTranslator(CombatState combat, ChatLog chat)
public CombatChatTranslator(
CombatState combat,
ChatLog chat,
Func<bool>? accepting = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_onDealt = HandleDamageDealt;
_onTaken = HandleDamageTaken;
_onMissed = HandleMissedOutgoing;
_onEvaded = HandleEvadedIncoming;
_onKill = HandleKillLanded;
_onDealt = value =>
{
if (accepting?.Invoke() != false) HandleDamageDealt(value);
};
_onTaken = value =>
{
if (accepting?.Invoke() != false) HandleDamageTaken(value);
};
_onMissed = value =>
{
if (accepting?.Invoke() != false) HandleMissedOutgoing(value);
};
_onEvaded = value =>
{
if (accepting?.Invoke() != false) HandleEvadedIncoming(value);
};
_onKill = (name, guid) =>
{
if (accepting?.Invoke() != false) HandleKillLanded(name, guid);
};
_combat.DamageDealtAccepted += _onDealt;
_combat.DamageTaken += _onTaken;

View file

@ -61,4 +61,11 @@ public sealed class LiveCommandBus : ICommandBus
$"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping.");
}
}
/// <summary>
/// Release every registered handler. Session-scoped owners call this
/// during teardown so a retained bus cannot keep an obsolete transport or
/// host object graph alive.
/// </summary>
public void Clear() => _handlers.Clear();
}

View file

@ -0,0 +1,301 @@
using System.Runtime.CompilerServices;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionCommandRouterTests
{
[Fact]
public void InactiveAndDisposedRouter_CannotReachTransport()
{
var sent = new List<string>();
var router = NewRouter(sendTalk: sent.Add);
router.Publish(new SendServerCommandCmd("@before"));
router.Activate();
router.Activate();
router.Publish(new SendServerCommandCmd("@active"));
router.Dispose();
router.Dispose();
router.Publish(new SendServerCommandCmd("@after"));
Assert.Equal(["@active"], sent);
Assert.False(router.IsActive);
}
[Fact]
public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy()
{
var tells = new List<(string Target, string Text)>();
var channels = new List<(uint Id, string Text)>();
var chat = new ChatLog();
var router = NewRouter(
chat: chat,
sendTell: (target, text) => tells.Add((target, text)),
sendChannel: (id, text) => channels.Add((id, text)));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello"));
router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group"));
Assert.Equal([("Friend", "hello")], tells);
Assert.Equal([(0x00000800u, "group")], channels);
Assert.Collection(
chat.Snapshot(),
entry =>
{
Assert.Equal(ChatKind.Tell, entry.Kind);
Assert.Equal("Friend", entry.Sender);
},
entry =>
{
Assert.Equal(ChatKind.Channel, entry.Kind);
Assert.Equal("Fellowship", entry.ChannelName);
});
}
[Fact]
public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho()
{
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
allegianceRoom: 0u,
generalRoom: 0x70000001u,
tradeRoom: 0u,
lfgRoom: 0u,
roleplayRoom: 0u,
olthoiRoom: 0u,
societyRoom: 0u,
societyCelestialHandRoom: 0u,
societyEldrytchWebRoom: 0u,
societyRadiantBloodRoom: 0u);
var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>();
var chat = new ChatLog();
var router = NewRouter(
chat: chat,
turbine: turbine,
playerGuid: () => 0x50000001u,
sendTurbine: (room, type, dispatch, sender, text, cookie) =>
sent.Add((room, type, dispatch, sender, text, cookie)));
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
Assert.Collection(sent, message =>
{
Assert.Equal(0x70000001u, message.Room);
Assert.Equal(0x02u, message.Type);
Assert.Equal(0x02u, message.Dispatch);
Assert.Equal(0x50000001u, message.Sender);
Assert.Equal("world", message.Text);
Assert.Equal(1u, message.Cookie);
});
Assert.Equal(0, chat.Count);
}
[Fact]
public void ActivateAfterDispose_IsRejected()
{
var router = NewRouter();
router.Dispose();
Assert.Throws<ObjectDisposedException>(router.Activate);
}
[Fact]
public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert()
{
using var sendEntered = new ManualResetEventSlim();
using var releaseSend = new ManualResetEventSlim();
var sent = new List<string>();
var router = NewRouter(sendTalk: text =>
{
sendEntered.Set();
releaseSend.Wait();
sent.Add(text);
});
router.Activate();
Task publish = Task.Run(() =>
router.Publish(new SendServerCommandCmd("@active")));
Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5)));
using var disposeStarted = new ManualResetEventSlim();
Task dispose = Task.Run(() =>
{
disposeStarted.Set();
router.Dispose();
});
Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5)));
Task firstCompletion = await Task.WhenAny(
dispose,
Task.Delay(TimeSpan.FromMilliseconds(100)));
Assert.NotSame(dispose, firstCompletion);
releaseSend.Set();
await Task.WhenAll(publish, dispose);
router.Publish(new SendServerCommandCmd("@after"));
Assert.Equal(["@active"], sent);
Assert.False(router.IsActive);
}
[Fact]
public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho()
{
var turbine = new TurbineChatState();
turbine.OnChannelsReceived(
0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u);
var sent = new List<string>();
var chat = new ChatLog();
LiveSessionCommandRouter? router = null;
router = NewRouter(
chat: chat,
turbine: turbine,
sendTurbine: (_, _, _, _, text, _) => sent.Add(text),
log: _ => router!.Dispose());
router.Activate();
router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world"));
Assert.Empty(sent);
Assert.Equal(0, chat.Count);
Assert.False(router.IsActive);
}
[Fact]
public void RetainedDisposedRouter_ReleasesCapturedTransportGraph()
{
(LiveSessionCommandRouter router, WeakReference<object> transport) =
CreateRouterWithCapturedTransport();
router.Dispose();
ForceFullCollection();
Assert.False(transport.TryGetTarget(out _));
GC.KeepAlive(router);
}
[Fact]
public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert()
{
(LiveSessionCommandRouter router, WeakReference<object> transport, Action<bool> callback) =
CreateRouterWithDelayedConfirmation();
router.Dispose();
ForceFullCollection();
Assert.False(transport.TryGetTarget(out _));
callback(true);
Assert.False(router.IsActive);
GC.KeepAlive(callback);
GC.KeepAlive(router);
}
private static LiveSessionCommandRouter NewRouter(
ChatLog? chat = null,
TurbineChatState? turbine = null,
Func<uint>? playerGuid = null,
Action<string>? sendTalk = null,
Action<string, string>? sendTell = null,
Action<uint, string>? sendChannel = null,
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
Action<string>? log = null,
ClientCommandController.Bindings? clientBindings = null) => new(
new LiveSessionCommandBindings(
clientBindings ?? NewClientBindings(),
chat ?? new ChatLog(),
turbine ?? new TurbineChatState(),
playerGuid ?? (() => 0u),
sendTalk ?? (_ => { }),
sendTell ?? ((_, _) => { }),
sendChannel ?? ((_, _) => { }),
sendTurbine ?? ((_, _, _, _, _, _) => { }),
log));
[MethodImpl(MethodImplOptions.NoInlining)]
private static (LiveSessionCommandRouter, WeakReference<object>)
CreateRouterWithCapturedTransport()
{
object transport = new object();
var weak = new WeakReference<object>(transport);
var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport));
return (router, weak);
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static (LiveSessionCommandRouter, WeakReference<object>, Action<bool>)
CreateRouterWithDelayedConfirmation()
{
object transport = new object();
var weak = new WeakReference<object>(transport);
Action<bool>? callback = null;
ClientCommandController.Bindings bindings = NewClientBindings() with
{
ShowConfirmation = (_, completion) => callback = completion,
Suicide = () => GC.KeepAlive(transport),
};
var router = NewRouter(clientBindings: bindings);
router.Activate();
router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty));
return (router, weak, Assert.IsType<Action<bool>>(callback));
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ForceFullCollection()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
private static ClientCommandController.Bindings NewClientBindings() => new(
TeleportToLifestone: () => { },
TeleportToMarketplace: () => { },
TeleportToPkArena: () => { },
TeleportToPkLiteArena: () => { },
TeleportToHouse: () => { },
TeleportToMansion: () => { },
QueryAge: () => { },
QueryBirth: () => { },
ToggleFrameRate: () => { },
ToggleUiLock: () => { },
ShowSystemMessage: _ => { },
ShowWeenieError: _ => { },
PlayerPublicWeenieBitfield: () => null,
ClientVersion: () => "test",
CurrentPosition: () => null,
LastOutsideCorpsePosition: () => null,
ShowConfirmation: (_, _) => { },
Suicide: () => { },
ClearChat: _ => { },
SaveUi: _ => { },
LoadUi: _ => { },
SaveAutoUi: () => { },
LoadAutoUi: () => { },
IsAway: () => false,
SetAway: _ => { },
SetAwayMessage: _ => { },
AcceptLootPermits: () => false,
SetAcceptLootPermits: _ => { },
DisplayConsent: () => { },
ClearConsent: () => { },
RemoveConsent: _ => { },
SendEmote: _ => { },
Friends: new FriendsState(),
AddFriend: _ => { },
RemoveFriend: _ => { },
ClearFriends: () => { },
RequestLegacyFriends: () => { },
Squelch: new SquelchState(),
ModifyCharacterSquelch: (_, _, _, _) => { },
ModifyAccountSquelch: (_, _) => { },
ModifyGlobalSquelch: (_, _) => { },
LastTeller: () => null,
ClearDesiredComponents: () => { },
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { });
}

View file

@ -0,0 +1,291 @@
using System.Net;
using System.Reflection;
using AcDream.App.Net;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Player;
using AcDream.Core.Social;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionEventRouterTests
{
[Fact]
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var counters = new Counters();
var combat = new CombatState();
var chat = new ChatLog();
var router = NewRouter(session, counters, combat: combat, chat: chat);
AssertSessionHandlerCounts(session, multiplier: 1);
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
Action<double> copiedTime = EventDelegate<Action<double>>(
session,
nameof(session.ServerTimeUpdated));
Action<uint> copiedTeleport = EventDelegate<Action<uint>>(
session,
nameof(session.TeleportStarted));
Action<WorldSession.PlayerIntPropertyUpdate> copiedPlayerInt =
EventDelegate<Action<WorldSession.PlayerIntPropertyUpdate>>(
session,
nameof(session.PlayerIntPropertyUpdated));
Action<CombatState.DamageDealt> copiedDamage =
EventDelegate<CombatState, Action<CombatState.DamageDealt>>(
combat,
nameof(combat.DamageDealtAccepted));
copiedTime(123d);
copiedTeleport(0x50000001u);
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
CombatStateWiring.CombatModePropertyId,
(int)CombatMode.Melee));
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f));
Assert.Equal(1, counters.ServerTime);
Assert.Equal(1, counters.Teleport);
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
Assert.Equal(1, chat.Count);
router.Dispose();
router.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
copiedTime(456d);
copiedTeleport(0x50000002u);
copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate(
CombatStateWiring.CombatModePropertyId,
(int)CombatMode.Magic));
copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f));
Assert.Equal(1, counters.ServerTime);
Assert.Equal(1, counters.Teleport);
Assert.Equal(CombatMode.Melee, combat.CurrentMode);
Assert.Equal(1, chat.Count);
}
[Fact]
public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var first = new Counters();
var second = new Counters();
var routerA = NewRouter(session, first);
var routerB = NewRouter(session, second);
AssertSessionHandlerCounts(session, multiplier: 2);
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
Assert.Equal(1, first.ServerTime);
Assert.Equal(1, second.ServerTime);
routerA.Dispose();
AssertSessionHandlerCounts(session, multiplier: 1);
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(2d);
Assert.Equal(1, first.ServerTime);
Assert.Equal(2, second.ServerTime);
routerB.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var first = new Counters();
var second = new Counters();
var routerA = NewRouter(session, first);
var routerB = NewRouter(session, second);
routerB.Dispose();
AssertSessionHandlerCounts(session, multiplier: 1);
EventDelegate<Action<double>>(session, nameof(session.ServerTimeUpdated))(1d);
Assert.Equal(1, first.ServerTime);
Assert.Equal(0, second.ServerTime);
routerA.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void ThrowingSink_DoesNotCorruptLaterTeardown()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var router = NewRouter(
session,
new Counters { ThrowOnServerTime = true });
Assert.Throws<InvalidOperationException>(() =>
EventDelegate<Action<double>>(
session,
nameof(session.ServerTimeUpdated))(1d));
router.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void ConstructionFailure_UnwindsEveryPriorRegistration()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
Assert.Throws<InvalidOperationException>(() => NewRouter(
session,
new Counters(),
step =>
{
if (step == 20)
throw new InvalidOperationException("injected construction failure");
}));
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
private static LiveSessionEventRouter NewRouter(
WorldSession session,
Counters counters,
Action<int>? constructionCheckpoint = null,
CombatState? combat = null,
ChatLog? chat = null) => new(
session,
new LiveEntitySessionSink(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>
{
if (counters.ThrowOnServerTime)
throw new InvalidOperationException("injected sink failure");
counters.ServerTime++;
}),
NewInventoryBindings(),
NewCharacterBindings(combat),
NewSocialBindings(chat),
constructionCheckpoint);
private static LiveInventorySessionBindings NewInventoryBindings() => new(
new ClientObjectTable(),
new LocalPlayerState(),
PlayerGuid: () => 0x50000001u,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
OnDesiredComponents: null,
ExternalContainers: new ExternalContainerState());
private static LiveCharacterSessionBindings NewCharacterBindings(
CombatState? combat = null) => new(
combat ?? new CombatState(),
new Spellbook(),
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
OnCharacterOptions: null,
ClientTime: () => 0d);
private static LiveSocialSessionBindings NewSocialBindings(
ChatLog? chat = null) => new(
chat ?? new ChatLog(),
new TurbineChatState(),
new FriendsState(),
new SquelchState());
private static WorldSession NewSession() =>
new(new IPEndPoint(IPAddress.Loopback, 9));
private static void AssertSessionHandlerCounts(
WorldSession session,
int multiplier)
{
string[] directEvents =
[
nameof(session.EntitySpawned),
nameof(session.EntityDeleted),
nameof(session.EntityPickedUp),
nameof(session.MotionUpdated),
nameof(session.PositionUpdated),
nameof(session.VectorUpdated),
nameof(session.StateUpdated),
nameof(session.ParentUpdated),
nameof(session.TeleportStarted),
nameof(session.AppearanceUpdated),
nameof(session.PlayPhysicsScriptReceived),
nameof(session.PlayPhysicsScriptTypeReceived),
nameof(session.EnvironChanged),
nameof(session.ServerTimeUpdated),
nameof(session.SpeechHeard),
nameof(session.ServerMessageReceived),
nameof(session.EmoteHeard),
nameof(session.SoulEmoteHeard),
nameof(session.PlayerKilledReceived),
nameof(session.TurbineChatReceived),
nameof(session.VitalUpdated),
nameof(session.VitalCurrentUpdated),
];
foreach (string eventName in directEvents)
Assert.Equal(multiplier, HandlerCount(session, eventName));
Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated)));
Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated)));
Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated)));
Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated)));
Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved)));
}
private static int HandlerCount(WorldSession session, string eventName) =>
(EventField(session, eventName).GetValue(session) as MulticastDelegate)?
.GetInvocationList()
.Length ?? 0;
private static TDelegate EventDelegate<TDelegate>(
WorldSession session,
string eventName)
where TDelegate : Delegate =>
Assert.IsType<TDelegate>(EventField(session, eventName).GetValue(session));
private static TDelegate EventDelegate<TOwner, TDelegate>(
TOwner owner,
string eventName)
where TOwner : class
where TDelegate : Delegate =>
Assert.IsType<TDelegate>(
typeof(TOwner).GetField(
eventName,
BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner));
private static FieldInfo EventField(WorldSession session, string eventName) =>
typeof(WorldSession).GetField(
eventName,
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"event field {eventName} not found");
private sealed class Counters
{
public int ServerTime;
public int Teleport;
public bool ThrowOnServerTime;
}
}

View file

@ -1,5 +1,6 @@
using System.Net;
using System.Reflection;
using System.Buffers.Binary;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -115,6 +116,35 @@ public sealed class WorldSessionWiringOwnershipTests
Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void GameEventWiring_AcceptanceGateSilencesCopiedOrInFlightDispatch()
{
var dispatcher = new GameEventDispatcher();
var combat = new CombatState();
bool accepting = true;
using IDisposable registration = GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
combat,
new Spellbook(),
new ChatLog(),
accepting: () => accepting);
byte[] first = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(first, 0x50000001u);
BinaryPrimitives.WriteSingleLittleEndian(first.AsSpan(4), 0.75f);
byte[] stale = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(stale, 0x50000001u);
BinaryPrimitives.WriteSingleLittleEndian(stale.AsSpan(4), 0.25f);
dispatcher.Dispatch(new GameEventEnvelope(
0u, 0u, GameEventType.UpdateHealth, first));
accepting = false;
dispatcher.Dispatch(new GameEventEnvelope(
0u, 0u, GameEventType.UpdateHealth, stale));
Assert.Equal(0.75f, combat.GetHealthPercent(0x50000001u));
}
private static WorldSession NewSession() =>
new(new IPEndPoint(IPAddress.Loopback, 9));

View file

@ -35,6 +35,20 @@ public sealed class LiveCommandBusTests
Assert.Throws<InvalidOperationException>(() => bus.Register<FakeCmd>(_ => { }));
}
[Fact]
public void Clear_ReleasesHandlersAndMakesExistingBusInert()
{
var bus = new LiveCommandBus();
int calls = 0;
bus.Register<FakeCmd>(_ => calls++);
bus.Publish(new FakeCmd(1));
bus.Clear();
bus.Publish(new FakeCmd(2));
Assert.Equal(1, calls);
}
}
public sealed class ChannelResolverTests