acdream never parsed retail's Sound event, so every server-driven cue was silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking, lifestone bind, spell resist, trap triggers, item mana depletion. SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent @0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared length 16, and holtburger's PlaySoundData. Playback reuses EntityEffectController's existing per-guid queue rather than adding a second one, because retail routes sounds through the SAME CObjectMaint blob queue as F754/F755: an event for a guid the client does not know yet is parked and drained by HandleCreateObject, so a creature that spawns and immediately grunts still grunts. Dropping it — the obvious alternative — would silently lose the cue. Sound joins Direct and Typed as a third PendingEffect kind so one readiness edge releases the whole mixed stream in order. AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the animation-hook path: the sound plays at the WIRE volume and the SoundTable entry's volume is ignored (the hook path does the opposite), while the entry's probability still gates it and its priority still drives eviction. An object with no SoundTable plays nothing, matching CPhysicsObj::play_sound @0x0050F460's early return. The no-window host parses and discards, exactly as it does for F754/F755 — sound is presentation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
643 lines
27 KiB
C#
643 lines
27 KiB
C#
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<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,
|
|
Action<SoundEvent> SoundEvent);
|
|
|
|
public sealed record LiveEnvironmentSessionSink(
|
|
Action<uint> EnvironChanged,
|
|
Action<double> ServerTimeUpdated);
|
|
|
|
public sealed record LiveInventorySessionBindings(
|
|
ClientObjectTable Objects,
|
|
Func<uint> PlayerGuid,
|
|
Action<IReadOnlyList<ShortcutEntry>>? OnShortcuts,
|
|
Action<uint>? OnUseDone,
|
|
ItemManaState? ItemMana,
|
|
ExternalContainerState? ExternalContainers,
|
|
Action<AppraiseInfoParser.Parsed>? 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, IReadOnlyDictionary<uint, uint>, uint>? ResolveSkillFormulaBonus,
|
|
Action<int, int>? OnSkillsUpdated,
|
|
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
|
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
|
|
Func<double>? 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);
|
|
|
|
public 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>
|
|
public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|
{
|
|
private readonly LiveSessionSubscriptionSet _subscriptions = new();
|
|
private readonly Action<int>? _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<int>? 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: character.Character.Options.Replace,
|
|
clientTime: character.ClientTime,
|
|
externalContainers: inventory.ExternalContainers,
|
|
vendor: inventory.Vendor,
|
|
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<ClientObject>(
|
|
h => inventory.Objects.ObjectAdded += h,
|
|
h => inventory.Objects.ObjectAdded -= h,
|
|
() => RecomputePlayerQualities(inventory, character));
|
|
SubscribeToRecompute<ClientObject>(
|
|
h => inventory.Objects.ObjectUpdated += h,
|
|
h => inventory.Objects.ObjectUpdated -= h,
|
|
() => RecomputePlayerQualities(inventory, character));
|
|
SubscribeToRecompute<ClientObject>(
|
|
h => inventory.Objects.ObjectRemoved += h,
|
|
h => inventory.Objects.ObjectRemoved -= h,
|
|
() => RecomputePlayerQualities(inventory, character));
|
|
SubscribeToRecompute<ClientObjectMove>(
|
|
h => inventory.Objects.ObjectMoved += h,
|
|
h => inventory.Objects.ObjectMoved -= h,
|
|
() => RecomputePlayerQualities(inventory, character));
|
|
SubscribeToRecompute<uint>(
|
|
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<LocalPlayerState.AttributeKind>(
|
|
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<LocalPlayerState.VitalKind>(
|
|
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<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 =>
|
|
character.Character.LocalPlayer.OnVitalUpdate(
|
|
vital.VitalId,
|
|
vital.Ranks,
|
|
vital.Start,
|
|
vital.Xp,
|
|
vital.Current));
|
|
Subscribe<PrivateUpdateVital.ParsedCurrent>(
|
|
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<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);
|
|
// 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
|
|
/// job is "something relevant changed, recompute" — thin wrapper over
|
|
/// <see cref="Subscribe{T}"/> that discards the payload.
|
|
/// </summary>
|
|
private void SubscribeToRecompute<T>(
|
|
Action<Action<T>> attach,
|
|
Action<Action<T>> detach,
|
|
Action recompute) =>
|
|
Subscribe(attach, detach, (T _) => recompute());
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
|
|
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
|
|
/// no payload).
|
|
/// </summary>
|
|
private void SubscribeParameterless(
|
|
Action<Action> attach,
|
|
Action<Action> detach,
|
|
Action sink)
|
|
{
|
|
Action handler = () =>
|
|
{
|
|
if (Volatile.Read(ref _accepting) != 0)
|
|
sink();
|
|
};
|
|
|
|
attach(handler);
|
|
_subscriptions.Add(() => detach(handler));
|
|
|
|
ConstructionCheckpoint();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
|
|
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
|
|
/// property 5, falling back to the summed carried burden) — the SAME
|
|
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
|
|
/// <c>InventoryController.RefreshBurden</c> already use for the burden
|
|
/// HUD. See the pseudocode doc §2/§9.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's
|
|
/// own <c>PublicWeenieDesc._bitfield</c> (PK/PKLite/Impenetrable
|
|
/// collision-exemption bits, already parsed at CreateObject time — see
|
|
/// <c>CreateObject.cs</c>'s <c>objectDescriptionFlags</c> read) and the
|
|
/// raw <c>PlayerKillerStatus</c>(0x86)/<c>LastPkAttackTimestamp</c>(0x91)
|
|
/// pair (retail <c>CACQualities::JumpStaminaCost</c>'s PK-timer bump)
|
|
/// into <see cref="RuntimeMovementSkillState"/>. Rides the SAME
|
|
/// ClientObject add/update/move/clear events <see cref="RecomputeBurden"/>
|
|
/// already reacts to — both live on the player's own row.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
|
|
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
|
|
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
|
|
/// effective-skill-zeroing gate (pseudocode doc §5).
|
|
/// </summary>
|
|
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)
|
|
{
|
|
if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message)
|
|
return;
|
|
|
|
chat.OnChannelBroadcast(
|
|
message.RoomId,
|
|
message.SenderName,
|
|
message.Message,
|
|
TurbineChatDisplayNames.Resolve(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(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<RetryableSubscription> _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<Exception>? 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);
|
|
}
|
|
}
|
|
}
|
|
}
|