Fourth sibling J-owner, built to the shape the other three established. It borrows nothing, because the retail client stores no quest state of its own — everything here is a projection of what the server pushed. Clearing at generation reset is safe for the same reason: a fresh session opens with a full 0x0314 replacement, so the reset cannot lose anything the next login will not immediately restate, while NOT clearing would show a previous character's quests. Three readings of the wire that would each lose contracts silently, one test apiece: a 0x0314 REPLACES rather than merges (merging resurrects contracts the server dropped); an empty 0x0314 clears rather than being ignored (it is how the server says "you have none", and ignoring it strands the last quest on screen); and a delete carries a full tracker struct, so it looks exactly like an add apart from one flag. Adding a teardown stage exposed a genuine trap: TeardownStageCount bounds the drain loop while GameRuntimeTeardownStage.Complete defines what the ledger demands, and nothing tied them together. Leave the constant behind and the new owner is never disposed at all, while the ledger goes on waiting for its flag — the runtime hangs in teardown rather than failing anywhere near the edit. The stage-ledger test now reads the constant by reflection and asserts it against the flag list, so the next owner fails at the edit instead. Campaign QT slice 3 of 6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
835 lines
38 KiB
C#
835 lines
38 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) so a per-character persisted
|
|
// settings.json that diverges from the server (an older save, or a
|
|
// changed allegiance/society) always converges back to what ACE
|
|
// actually has — the server, not any local default, is authoritative.
|
|
// 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,
|
|
// Campaign FA slice FA2 (2026-08-12): the two sibling J-owners.
|
|
// Trailing/optional so every existing positional caller (Headless)
|
|
// compiles unchanged (docs/research/2026-08-11-fa-acdream-seams.md
|
|
// §2.4 — "the established compatibility convention").
|
|
RuntimeFellowshipState? Fellowship = null,
|
|
RuntimeAllegianceState? Allegiance = null,
|
|
// Secure trade (2026-08-14): the third sibling J-owner, same
|
|
// trailing/optional compatibility convention.
|
|
RuntimeTradeState? Trade = null,
|
|
// Batch C (Map/House toolbar panel, 2026-08-17): same trailing/optional
|
|
// compatibility convention — a minimal owner (RuntimeHouseState's own
|
|
// class doc), not a full sibling J-owner.
|
|
RuntimeHouseState? House = null,
|
|
// Campaign QT (2026-08-21): the fourth sibling J-owner, same
|
|
// trailing/optional compatibility convention.
|
|
RuntimeContractState? Contracts = 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, trailerTruncated) =>
|
|
{
|
|
// MF-2 (Campaign OP OP7 review fix, 2026-08-11): a
|
|
// trailer-truncated PlayerDescription's option words are
|
|
// the parser's zero placeholders, not server truth (R3,
|
|
// above). Installing them AND notifying subscribers let a
|
|
// headless seeder diff against zero and flush it into
|
|
// 0x01A1 even though HasServerSeed stayed armed from an
|
|
// earlier complete seed. Root fix: on truncation, install
|
|
// NOTHING and notify NO ONE — the words the caller last
|
|
// had (real, seeded) remain current.
|
|
if (trailerTruncated)
|
|
return;
|
|
character.Character.Options.Replace(
|
|
options1, options2, armServerSeed: true);
|
|
character.OnCharacterOptionsChanged?.Invoke(options1, options2);
|
|
},
|
|
clientTime: character.ClientTime,
|
|
externalContainers: inventory.ExternalContainers,
|
|
vendor: inventory.Vendor,
|
|
onInterfaceText: social.AddText,
|
|
accepting: IsAccepting,
|
|
// Campaign FA slice FA2 (2026-08-12): fellowship/allegiance
|
|
// sink registration — the ONE inbound registration site
|
|
// (docs/research/2026-08-11-fa-acdream-seams.md §2.1),
|
|
// reached identically by both hosts since LiveSessionEventRouter
|
|
// itself is shared (LiveSessionEventRouter.Attach, K-slice
|
|
// unification).
|
|
//
|
|
// FA2 fix-round SHOULD-FIX 1 (2026-08-12,
|
|
// docs/research/2026-08-12-fa2-review-mechanism.md): pass
|
|
// each delegate hole CONDITIONALLY on the matching owner
|
|
// being supplied, rather than an always-non-null lambda that
|
|
// no-ops through `?.`. GameEventWiring only registers a
|
|
// handler (and so only counts toward
|
|
// GameEventDispatcher.GetUnhandledCount) when its delegate
|
|
// hole is non-null — an always-non-null lambda made every
|
|
// caller without an owner (bare-ChatLog tests, a future
|
|
// partial host) silently read 0 unhandled events for these
|
|
// 9 types even though the parse result was discarded.
|
|
onFellowshipFullUpdate: social.Fellowship is { } fellowshipFull
|
|
? fellowshipFull.ApplyFullUpdate
|
|
: null,
|
|
onFellowshipUpdateFellow: social.Fellowship is { } fellowshipUpdate
|
|
? fellowshipUpdate.ApplyUpdateFellow
|
|
: null,
|
|
onFellowshipQuit: social.Fellowship is { } fellowshipQuit
|
|
? quitterGuid => fellowshipQuit.ApplyQuit(quitterGuid, inventory.PlayerGuid())
|
|
: null,
|
|
onFellowshipDismiss: social.Fellowship is { } fellowshipDismiss
|
|
? dismissedGuid => fellowshipDismiss.ApplyDismiss(dismissedGuid, inventory.PlayerGuid())
|
|
: null,
|
|
onFellowshipDisband: social.Fellowship is { } fellowshipDisband
|
|
? fellowshipDisband.ApplyDisband
|
|
: null,
|
|
onAllegianceUpdate: social.Allegiance is { } allegianceUpdate
|
|
? allegianceUpdate.ApplyUpdate
|
|
: null,
|
|
onAllegianceUpdateDone: social.Allegiance is { } allegianceUpdateDone
|
|
? allegianceUpdateDone.ApplyUpdateDone
|
|
: null,
|
|
onAllegianceUpdateAborted: social.Allegiance is { } allegianceUpdateAborted
|
|
? allegianceUpdateAborted.ApplyUpdateAborted
|
|
: null,
|
|
onAllegianceLoginNotification: social.Allegiance is { } allegianceLogin
|
|
? notice => allegianceLogin.ApplyLoginNotification(
|
|
notice.CharacterGuid,
|
|
notice.IsLoggedIn)
|
|
: null,
|
|
// Secure trade (2026-08-14): same conditional delegate-hole
|
|
// discipline. Self-vs-partner comparisons use the exact
|
|
// player guid the fellowship holes above already borrow.
|
|
onTradeRegister: social.Trade is { } tradeRegister
|
|
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
|
|
: null,
|
|
onTradeClose: social.Trade is { } tradeClose
|
|
? _ =>
|
|
{
|
|
tradeClose.ApplyClose();
|
|
// Handle_Trade__Recv_CloseTrade @ 0x0056DE30 shows
|
|
// this on EVERY close reason, 0x1A ClientLocal.
|
|
social.AddText?.Invoke(
|
|
AcDream.Core.Chat.ClientTextRefusals.TradeCancelled,
|
|
RetailLogTextType.ClientLocal);
|
|
}
|
|
: null,
|
|
onTradeAdd: social.Trade is { } tradeAdd
|
|
? tradeAdd.ApplyAdd
|
|
: null,
|
|
onTradeRemove: social.Trade is { } tradeRemove
|
|
? tradeRemove.ApplyRemove
|
|
: null,
|
|
onTradeAccept: social.Trade is { } tradeAccept
|
|
? whoAccepted => tradeAccept.ApplyAccept(whoAccepted, inventory.PlayerGuid())
|
|
: null,
|
|
onTradeDecline: social.Trade is { } tradeDecline
|
|
? whoDeclined => tradeDecline.ApplyDecline(whoDeclined, inventory.PlayerGuid())
|
|
: null,
|
|
onTradeReset: social.Trade is { } tradeReset
|
|
? _ => tradeReset.ApplyReset()
|
|
: null,
|
|
onTradeFailure: social.Trade is { } tradeFailure
|
|
? tradeFailure.ApplyFailure
|
|
: null,
|
|
onTradeClearAcceptance: social.Trade is { } tradeClear
|
|
? tradeClear.ApplyClearAcceptance
|
|
: null,
|
|
// Batch C (Map/House toolbar panel, 2026-08-17): same
|
|
// conditional delegate-hole discipline as trade above.
|
|
onHouseData: social.House is { } houseData
|
|
? data => houseData.ApplyHouseData(data, inventory.PlayerGuid())
|
|
: null,
|
|
onHouseStatus: social.House is { } houseStatus
|
|
? weenieError => houseStatus.ApplyHouseStatus(weenieError, inventory.PlayerGuid())
|
|
: null,
|
|
// Campaign QT (2026-08-21): same conditional delegate-hole
|
|
// discipline as house above.
|
|
onContractTable: social.Contracts is { } contractTable
|
|
? contractTable.ApplyTable
|
|
: null,
|
|
onContractUpdate: social.Contracts is { } contractUpdate
|
|
? contractUpdate.ApplyUpdate
|
|
: null));
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|