Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new TurbineChatMembershipGate checks Turbine availability and the player's own Hear*Chat option before sending, raising "Turbine chat is not available." or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter) and headless (DirectGameRuntimeCommandAdapter) send paths so they can't diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md — ACE's TurbineChat implementation is complete and on by default; the real bug was treating Hear*Chat as a display filter instead of room membership. Also: implements SetSingleCharacterOption (0x0005), the only wire message that actually joins/leaves a Turbine room, and wires the five Settings Chat toggles to it (publish on Save, changed bits only) plus seeds ChatSettings from the server's own CharacterOptions2 on every PlayerDescription. Fixes the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally consulted). Routes /a to Turbine unconditionally (retail's @a never falls back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead of discarding it silently. Deletes the malformed, callerless SetCharacterOptions (0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders. Files every AC-specific algorithm change cites the named retail decomp (SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0, GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks. Register rows AP-181 (no client-side spam throttle) and UN-9 (an incidentally-discovered CharacterOptions1.Default literal mismatch, not investigated further) filed per the divergence-register rule. 11,957 passed / 4 skipped / 0 failed (full Release suite, up from the 11,916/4/0 baseline). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
704 lines
30 KiB
C#
704 lines
30 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,
|
|
// Campaign CH slice CH3 (2026-08-09): fires with the raw
|
|
// (options1, options2) pair whenever a fresh PlayerDescription lands —
|
|
// AFTER Character.Options.Replace has already committed them. Lets the
|
|
// graphical host reseed its Settings "Hear * Chat" draft from server
|
|
// truth (research doc §5.2/§6.4: the local ChatSettings.Default lies
|
|
// relative to ACE's CharacterOptions2.Default). Optional/nullable so
|
|
// every existing caller compiles unchanged.
|
|
Action<uint, uint>? OnCharacterOptionsChanged = null);
|
|
|
|
public sealed record LiveSocialSessionBindings(
|
|
ChatLog Chat,
|
|
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<string, RetailLogTextType>? AddText = null);
|
|
|
|
/// <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: (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<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,
|
|
// 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<ServerMessage.Parsed>(
|
|
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<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)
|
|
{
|
|
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<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);
|
|
}
|
|
}
|
|
}
|
|
}
|