using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Player; using AcDream.Core.Properties; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Session; public sealed record LiveEntitySessionSink( Action Spawned, Action Deleted, Action PickedUp, Action MotionUpdated, Action PositionUpdated, Action VectorUpdated, Action StateUpdated, Action ParentUpdated, Action TeleportStarted, Action AppearanceUpdated, Action PlayPhysicsScript, Action PlayPhysicsScriptType, Action SoundEvent); public sealed record LiveEnvironmentSessionSink( Action EnvironChanged, Action ServerTimeUpdated); public sealed record LiveInventorySessionBindings( ClientObjectTable Objects, Func PlayerGuid, Action>? OnShortcuts, Action? OnUseDone, ItemManaState? ItemMana, ExternalContainerState? ExternalContainers, Action? OnAppraisal = null, // Slice 5.3: the vendor browse session owner. Trailing/optional so every // existing positional caller (Headless) compiles unchanged. VendorState? Vendor = null); public sealed record LiveCharacterSessionBindings( CombatState Combat, RuntimeCharacterState Character, Func, uint>? ResolveSkillFormulaBonus, Action? OnSkillsUpdated, Action? OnConfirmationRequest, Action? OnConfirmationDone, Func? ClientTime, // Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden, // stamina, OR (vitae/enchantment-adjusted) skill values change mid- // session — the reactive re-apply-to-the-live-controller seam, mirroring // OnSkillsUpdated's existing shape. Optional/nullable so every existing // caller (including Headless's OnSkillsUpdated: null pattern) compiles // unchanged. Action? OnMovementStatsUpdated = null, // Campaign CH slice CH3 (2026-08-09): fires with the raw // (options1, options2) pair whenever a fresh PlayerDescription lands — // AFTER Character.Options.Replace has already committed them. Lets the // graphical host reseed its Settings "Hear * Chat" draft from server // truth (research doc §5.2/§6.4: the local ChatSettings.Default lies // relative to ACE's CharacterOptions2.Default). Optional/nullable so // every existing caller compiles unchanged. Action? OnCharacterOptionsChanged = null); public sealed record LiveSocialSessionBindings( ChatLog Chat, TurbineChatState TurbineChat, FriendsState? Friends, SquelchState? Squelch, // Campaign CH slice CH2: the retail-faithful text/type router // (RuntimeCommunicationState.AddText). Optional/nullable so every // existing caller (including tests that build a bare ChatLog with no // owning RuntimeCommunicationState) compiles unchanged; GameEventWiring // falls back to its pre-CH2 chat-only behavior when this is null. Action? AddText = null); /// /// Owns every inbound subscription for one exact live session. Domain state /// remains in the supplied sinks; this class owns only routing and teardown. /// public sealed class LiveSessionEventRouter : ILiveSessionEventRouting { private readonly LiveSessionSubscriptionSet _subscriptions = new(); private readonly Action? _constructionCheckpoint; private readonly WorldSession _session; private readonly LiveEntitySessionSink _entities; private readonly LiveEnvironmentSessionSink _environment; private readonly LiveInventorySessionBindings _inventory; private readonly LiveCharacterSessionBindings _character; private readonly LiveSocialSessionBindings _social; private int _constructionStep; private int _accepting; private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed public LiveSessionEventRouter( WorldSession session, LiveEntitySessionSink entities, LiveEnvironmentSessionSink environment, LiveInventorySessionBindings inventory, LiveCharacterSessionBindings character, LiveSocialSessionBindings social, Action? constructionCheckpoint = null) { ArgumentNullException.ThrowIfNull(session); Validate(entities, environment, inventory, character, social); _session = session; _entities = entities; _environment = environment; _inventory = inventory; _character = character; _social = social; _constructionCheckpoint = constructionCheckpoint; } public void Attach() { if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0) throw new InvalidOperationException( "Live-session event routing can only attach once."); Interlocked.Exchange(ref _accepting, 1); WorldSession session = _session; LiveEntitySessionSink entities = _entities; LiveEnvironmentSessionSink environment = _environment; LiveInventorySessionBindings inventory = _inventory; LiveCharacterSessionBindings character = _character; LiveSocialSessionBindings social = _social; 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, character.Character.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.SoundEventReceived += h, h => session.SoundEventReceived -= h, entities.SoundEvent); 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.Character.Spellbook, social.Chat, character.Character.LocalPlayer, social.TurbineChat, onSkillsUpdated: (runSkill, jumpSkill) => { // Campaign P Slice P1 (2026-07-30): route the PD/skill // base through the vitae/enchantment-adjusted recompute // (CEnchantmentRegistry::EnchantSkill) instead of writing // MovementSkills directly — see the pseudocode doc §9. character.Character.UpdateMovementSkillBase( runSkill, jumpSkill); character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill); character.OnMovementStatsUpdated?.Invoke(); }, resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, onShortcuts: inventory.OnShortcuts, playerGuid: inventory.PlayerGuid, onUseDone: inventory.OnUseDone, onAppraisal: inventory.OnAppraisal, itemMana: inventory.ItemMana, onConfirmationRequest: character.OnConfirmationRequest, onConfirmationDone: character.OnConfirmationDone, friends: social.Friends, squelch: social.Squelch, onDesiredComponents: null, onCharacterOptions: (options1, options2) => { character.Character.Options.Replace(options1, options2); character.OnCharacterOptionsChanged?.Invoke(options1, options2); }, clientTime: character.ClientTime, externalContainers: inventory.ExternalContainers, vendor: inventory.Vendor, onInterfaceText: social.AddText, accepting: IsAccepting)); ConstructionCheckpoint(); // Campaign P Slice P1 (2026-07-30): burden recompute triggers — // the SAME event set IndicatorBarController.UpdateBurden already // reacts to (Strength + augmentation property 0xE6 + // EncumbranceVal property 5, falling back to SumCarriedBurden). // See the pseudocode doc §9. Campaign P Slice P3 (2026-07-30) // rides the SAME triggers for the player's own PWD bitfield // (PK/PKLite/Impenetrable) and PlayerKillerStatus/ // LastPkAttackTimestamp — all live on the SAME ClientObject row. SubscribeToRecompute( h => inventory.Objects.ObjectAdded += h, h => inventory.Objects.ObjectAdded -= h, () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectUpdated += h, h => inventory.Objects.ObjectUpdated -= h, () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectRemoved += h, h => inventory.Objects.ObjectRemoved -= h, () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ObjectMoved += h, h => inventory.Objects.ObjectMoved -= h, () => RecomputePlayerQualities(inventory, character)); SubscribeToRecompute( h => inventory.Objects.ContainerContentsReplaced += h, h => inventory.Objects.ContainerContentsReplaced -= h, () => RecomputePlayerQualities(inventory, character)); SubscribeParameterless( h => inventory.Objects.Cleared += h, h => inventory.Objects.Cleared -= h, () => RecomputePlayerQualities(inventory, character)); Subscribe( h => character.Character.LocalPlayer.AttributeChanged += h, h => character.Character.LocalPlayer.AttributeChanged -= h, kind => { if (kind == LocalPlayerState.AttributeKind.Strength) RecomputeBurden(inventory, character); }); SubscribeParameterless( h => character.Character.Spellbook.EnchantmentsChanged += h, h => character.Character.Spellbook.EnchantmentsChanged -= h, () => RecomputeBurden(inventory, character)); // Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's // stamina==0 effective-skill-zeroing gate (pseudocode doc §5). Subscribe( h => character.Character.LocalPlayer.Changed += h, h => character.Character.LocalPlayer.Changed -= h, kind => RecomputeStamina(kind, character)); _subscriptions.Add(new CombatChatTranslator( character.Combat, social.Chat, IsAccepting)); ConstructionCheckpoint(); Subscribe(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech => social.Chat.OnLocalSpeech( speech.SenderName, speech.Text, speech.SenderGuid, speech.IsRanged, // speech.ChatType is passed through VERBATIM — retail's // Handle_Communication__HearSpeech @0x005712A0 feeds the // raw wire word straight into AddTextToScroll with zero // remapping (research doc §3.3 / HearSpeech.cs doc). speech.ChatType)); // 0xF7E0 ServerMessage — Campaign CH slice CH2: routed through // AddText with the wire chatType verbatim, matching retail's // Handle_Communication__TextboxString @0x0057D3A0 // (AddTextToScroll(text, wireChatType, 1, 0) — the wire type // decides chat vs SpewBox, exactly like every other producer). Subscribe( h => session.ServerMessageReceived += h, h => session.ServerMessageReceived -= h, message => { if (social.AddText is { } addText) addText(message.Message, (RetailLogTextType)message.ChatType); else social.Chat.OnSystemMessage(message.Message, message.ChatType); }); Subscribe(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote => social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid)); Subscribe(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote => social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid)); Subscribe( h => session.PlayerKilledReceived += h, h => session.PlayerKilledReceived -= h, killed => social.Chat.OnPlayerKilled( killed.DeathMessage, killed.VictimGuid, killed.KillerGuid)); Subscribe( h => session.TurbineChatReceived += h, h => session.TurbineChatReceived -= h, parsed => RouteTurbineChat(social.Chat, parsed)); Subscribe(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital => character.Character.LocalPlayer.OnVitalUpdate( vital.VitalId, vital.Ranks, vital.Start, vital.Xp, vital.Current)); Subscribe( h => session.VitalCurrentUpdated += h, h => session.VitalCurrentUpdated -= h, vital => character.Character.LocalPlayer.OnVitalCurrent( vital.VitalId, vital.Current)); if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); } catch { Interlocked.Exchange(ref _accepting, 0); Interlocked.Exchange(ref _lifecycleState, 3); throw; } } public bool Accepting => IsAccepting(); public void Dispose() { Interlocked.Exchange(ref _accepting, 0); Interlocked.Exchange(ref _lifecycleState, 3); _subscriptions.Dispose(); } private void Subscribe( Action> attach, Action> detach, Action sink) { Action handler = value => { if (Volatile.Read(ref _accepting) != 0) sink(value); }; attach(handler); // Add assumes cleanup ownership before it can call an external detach. // If the set is already closing, it retains/retries that exact edge; // calling detach again here would replay a successful removal. _subscriptions.Add(() => detach(handler)); ConstructionCheckpoint(); } /// /// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY /// job is "something relevant changed, recompute" — thin wrapper over /// that discards the payload. /// private void SubscribeToRecompute( Action> attach, Action> detach, Action recompute) => Subscribe(attach, detach, (T _) => recompute()); /// /// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of /// (ClientObjectTable.Cleared carries /// no payload). /// private void SubscribeParameterless( Action attach, Action detach, Action sink) { Action handler = () => { if (Volatile.Read(ref _accepting) != 0) sink(); }; attach(handler); _subscriptions.Add(() => detach(handler)); ConstructionCheckpoint(); } /// /// Campaign P Slice P1 (2026-07-30): retail CACQualities::InqLoad /// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal /// property 5, falling back to the summed carried burden) — the SAME /// input assembly IndicatorBarController.UpdateBurden / /// InventoryController.RefreshBurden already use for the burden /// HUD. See the pseudocode doc §2/§9. /// private static void RecomputeBurden( LiveInventorySessionBindings inventory, LiveCharacterSessionBindings character, bool notify = true) { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); int strength = character.Character.LocalPlayer .GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0; int aug = playerObject?.Properties.GetInt( (uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0; int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug); int burden = playerObject is not null && playerObject.Properties.Ints.TryGetValue( (uint)PropertyInt.EncumbranceVal, out int wireBurden) ? wireBurden : inventory.Objects.SumCarriedBurden(player); float load = EncumbranceSystem.Load(capacity, burden); character.Character.MovementSkills.UpdateBurden(load); if (notify) character.OnMovementStatsUpdated?.Invoke(); } private static void RecomputePlayerQualities( LiveInventorySessionBindings inventory, LiveCharacterSessionBindings character) { RecomputeBurden(inventory, character, notify: false); RecomputePvpStatus(inventory, character, notify: false); uint player = inventory.PlayerGuid(); PropertyBundle properties = inventory.Objects.Get(player)?.Properties ?? character.Character.LocalPlayer.Properties; character.Character.UpdateMovementSkillAugmentations( PlayerSkillMath.AugmentationBonuses.FromProperties(properties)); character.OnMovementStatsUpdated?.Invoke(); } /// /// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's /// own PublicWeenieDesc._bitfield (PK/PKLite/Impenetrable /// collision-exemption bits, already parsed at CreateObject time — see /// CreateObject.cs's objectDescriptionFlags read) and the /// raw PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91) /// pair (retail CACQualities::JumpStaminaCost's PK-timer bump) /// into . Rides the SAME /// ClientObject add/update/move/clear events /// already reacts to — both live on the player's own row. /// private static void RecomputePvpStatus( LiveInventorySessionBindings inventory, LiveCharacterSessionBindings character, bool notify = true) { uint player = inventory.PlayerGuid(); ClientObject? playerObject = inventory.Objects.Get(player); uint bitfield = playerObject?.PublicWeenieBitfield ?? 0u; int pkStatus = playerObject?.Properties.Ints.TryGetValue( (uint)PropertyInt.PlayerKillerStatus, out int wirePkStatus) == true ? wirePkStatus : -1; float? lastPkAttackTimestamp = playerObject?.Properties.Floats.TryGetValue( (uint)PropertyFloat.LastPkAttackTimestamp, out double wireTimestamp) == true ? (float)wireTimestamp : null; character.Character.MovementSkills.UpdateOwnPwdBitfield(bitfield); character.Character.MovementSkills.UpdatePlayerKillerStatus( pkStatus, lastPkAttackTimestamp); if (notify) character.OnMovementStatsUpdated?.Invoke(); } /// /// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital /// changes into — feeds /// CACQualities::InqRunRate/InqJumpVelocity's stamina==0 /// effective-skill-zeroing gate (pseudocode doc §5). /// private static void RecomputeStamina( LocalPlayerState.VitalKind kind, LiveCharacterSessionBindings character) { if (kind != LocalPlayerState.VitalKind.Stamina) return; if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina) is not LocalPlayerState.VitalSnapshot stamina) { return; } character.Character.MovementSkills.UpdateStamina((int)stamina.Current); character.OnMovementStatsUpdated?.Invoke(); } private void ConstructionCheckpoint() => _constructionCheckpoint?.Invoke(++_constructionStep); private bool IsAccepting() => Volatile.Read(ref _accepting) != 0; private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed) { switch (parsed.Body) { case TurbineChat.Payload.EventSendToRoom message: // message.RoomId is an opaque per-session Turbine room GUID, // not a legacy channel bitflag — ChatLog.OnChannelBroadcast's // default (legacy-bit) LogTextType derivation would // misclassify it, so the room's own ChatType maps to // LogTextType explicitly here instead. chat.OnChannelBroadcast( message.RoomId, message.SenderName, message.Message, logTextType: TurbineChatDisplayNames.LogTextType(message.ChatType), channelName: TurbineChatDisplayNames.Resolve( message.RoomId, message.ChatType)); return; case TurbineChat.Payload.Response { HResult: not 0 } response: // CH3 (2026-08-09, research doc §2.7/§6.6): previously // discarded unconditionally — a server-side send rejection // was completely invisible. HResult==0 (the overwhelmingly // common case) stays silent, matching retail's own quiet // success ack. chat.OnSystemMessage( "TurbineChat send rejected " + $"(hresult=0x{unchecked((uint)response.HResult):X8}).", (uint)RetailLogTextType.Default); return; default: // Response with HResult==0, or Unknown — nothing to surface. return; } } private static void Validate( 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(entities.SoundEvent); ArgumentNullException.ThrowIfNull(environment.EnvironChanged); ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated); ArgumentNullException.ThrowIfNull(inventory.Objects); ArgumentNullException.ThrowIfNull(inventory.PlayerGuid); ArgumentNullException.ThrowIfNull(character.Combat); ArgumentNullException.ThrowIfNull(character.Character); ArgumentNullException.ThrowIfNull(social.Chat); ArgumentNullException.ThrowIfNull(social.TurbineChat); } } internal sealed class LiveSessionSubscriptionSet : IDisposable { private readonly object _gate = new(); private readonly List _subscriptions = []; private bool _disposeRequested; public void Add(IDisposable subscription) { ArgumentNullException.ThrowIfNull(subscription); AddRetained(new RetryableSubscription(subscription.Dispose)); } public void Add(Action unsubscribe) { ArgumentNullException.ThrowIfNull(unsubscribe); AddRetained(new RetryableSubscription(unsubscribe)); } private void AddRetained(RetryableSubscription retained) { bool disposeNow; lock (_gate) { disposeNow = _disposeRequested; _subscriptions.Add(retained); } if (!disposeNow) return; retained.Dispose(); throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet)); } public void Dispose() { RetryableSubscription[] subscriptions; lock (_gate) { _disposeRequested = true; subscriptions = _subscriptions.ToArray(); } List? errors = null; for (int index = subscriptions.Length - 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 RetryableSubscription(Action dispose) : IDisposable { private readonly object _gate = new(); private Action? _dispose = dispose; private bool _executing; private int _executingThreadId; public void Dispose() { Action? operation; int threadId = Environment.CurrentManagedThreadId; lock (_gate) { while (_executing) { if (_executingThreadId == threadId) { throw new InvalidOperationException( "Live-session subscription cleanup cannot complete reentrantly."); } Monitor.Wait(_gate); } operation = _dispose; if (operation is null) return; _executing = true; _executingThreadId = threadId; } try { operation(); } catch { CompleteAttempt(succeeded: false); throw; } CompleteAttempt(succeeded: true); } private void CompleteAttempt(bool succeeded) { lock (_gate) { if (succeeded) _dispose = null; _executing = false; _executingThreadId = 0; Monitor.PulseAll(_gate); } } } }