staged-item trading marker - Cancel text: ClientTradeSystem::Handle_Trade__Recv_CloseTrade @0x0056DE30 shows "The trade has been cancelled." UNCONDITIONALLY (every close reason) as 0x1A ClientLocal - the yellow top-center SpewBox line. Wired at the router's onTradeClose beside ApplyClose; the string lives in ClientTextRefusals with its citation. - Staged-item marker: retail's mechanism decoded end-to-end - the UIItem prototype (catalog 0x21000037) authors overlay child 0x10000438 (sprite 0x06001DAE, the green frame + corner trade icon), bound @0x004E18FC and SetVisible(tradeState != 0) @0x004E2420; gmSecureTradeUI::AddItem @0x004CA801 sets ACCWeenieObject::SetTradeState(1) on YOUR staged items. Ported as: UiItemSlot.ShowTradeOverlay + TradeOverlaySprite (drawn over the icon), set on the trade window's self-grid cells; and RuntimeTradeState now borrows the canonical object table and maintains ClientObject.TradeState (1 at stage, 0 at remove/failure/ reset/close/clear) - which also brings the ALREADY-PORTED placement policy's "You cannot move an item while it is being traded" refusal to life (its input field previously had no live producer). Runtime 1,626, App 4,992/3 - green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
812 lines
37 KiB
C#
812 lines
37 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);
|
|
|
|
/// <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));
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|