using AcDream.Core.Player; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; using AcDream.Core.Items; using AcDream.Core.Properties; namespace AcDream.Runtime.Gameplay; public readonly record struct RuntimeCharacterOwnershipSnapshot( bool IsDisposed, bool InternalSubscriptionsAttached, int LearnedSpellCount, int ActiveEnchantmentCount, int DesiredComponentCount, int FavoriteSpellCount, int VitalCount, int AttributeCount, int SkillCount, int PositionCount, int PropertyCount, bool OptionsAreDefaults, bool MovementSkillsAreReset, /// C0-2: is back at retail's default (). bool AutonomyIsDefault = true) { public bool IsConverged => IsDisposed && !InternalSubscriptionsAttached && LearnedSpellCount == 0 && ActiveEnchantmentCount == 0 && DesiredComponentCount == 0 && FavoriteSpellCount == 0 && VitalCount == 0 && AttributeCount == 0 && SkillCount == 0 && PositionCount == 0 && PropertyCount == 0 && OptionsAreDefaults && MovementSkillsAreReset && AutonomyIsDefault; } /// /// Canonical presentation-independent owner for the local character's magic /// and player-sheet state. The two objects form one lifetime group because /// vital maxima read active enchantments from this exact spellbook. /// public sealed class RuntimeCharacterState : IDisposable { /// ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5). public const uint RunSkillId = 24u; /// ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5). public const uint JumpSkillId = 22u; /// /// C0-2/F5(b): retail CommandInterpreter's own default, set at /// construction (pseudo-C 699752, this->autonomy_level = 2;, a /// direct field write, not a SetAutonomyLevel call) and by the /// command-line-only override at admission /// (command_line_autonomy_level, pseudo-C 1088429, itself /// defaulting to 0x2). Exactly ONE retail caller of /// CommandInterpreter::SetAutonomyLevel exists in the named /// retail decomp - the startup construction path at pseudo-C 94102 /// (cmdinterp->vtable->SetAutonomyLevel(cmdinterp, command_line_autonomy_level)) /// - it is a startup/debug knob, not a per-play-session gameplay /// toggle, so acdream's own default matches retail's value exactly and /// nothing in ordinary play ever changes it. /// public const uint FullAutonomyLevel = 2u; private bool _disposed; private long _characterRevision; private long _spellbookRevision; private bool _internalSubscriptionsAttached; private uint _autonomyLevel = FullAutonomyLevel; /// /// Campaign P Slice P1 (2026-07-30): the pre-EnchantSkill base /// run/jump skill (formulaBonus+init+ranks, as parsed from /// PlayerDescription) — kept so /// can re-derive the vitae/enchantment-adjusted value purely from a /// spellbook change, without waiting for a fresh skill push. -1 = /// unknown (mirrors 's own /// sentinel convention). /// private int _runSkillBase = -1; private int _jumpSkillBase = -1; private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations; public RuntimeCharacterState(SpellTable? spellTable = null) { Spellbook = new Spellbook(spellTable); LocalPlayer = new LocalPlayerState(Spellbook); Options = new RuntimeCharacterOptionsState(); MovementSkills = new RuntimeMovementSkillState(); View = new CharacterView(this); Spellbook.StateChanged += OnSpellbookChanged; Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement; LocalPlayer.Changed += OnVitalChanged; LocalPlayer.AttributeChanged += OnAttributeChanged; LocalPlayer.CharacterChanged += OnCharacterChanged; _internalSubscriptionsAttached = true; } public Spellbook Spellbook { get; } public LocalPlayerState LocalPlayer { get; } public RuntimeCharacterOptionsState Options { get; } public RuntimeMovementSkillState MovementSkills { get; } public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; /// /// Campaign CH slice CH3 (2026-08-09): retail /// PlayerModule::IsOlthoi / ACE Player.IsOlthoiPlayer /// (Player.cs:169, HeritageGroup == Olthoi || /// HeritageGroup == OlthoiAcid) — the gate /// TurbineChatMembershipGate consults for the Olthoi room instead /// of a Hear*Chat option (there is no such toggle; only heritage /// gates that room). Reads the already-parsed /// PropertyInt.HeritageGroup (188) off the local player's own /// property bundle — 0 (unparsed/unknown) reads as "not Olthoi", matching /// every other heritage-gated check in this codebase. /// public bool IsOlthoiPlayer { get { int heritage = LocalPlayer.Properties.GetInt((uint)PropertyInt.HeritageGroup, 0); return heritage == 12 || heritage == 13; // HeritageGroup.Olthoi / OlthoiAcid } } /// Retail CommandInterpreter::GetAutonomyLevel. public uint AutonomyLevel => Volatile.Read(ref _autonomyLevel); /// /// C0-2: retail CommandInterpreter::UsePositionFromServer /// (pseudo-C 699506-699512: result = this->autonomy_level != 2;). /// This is the source /// /// binds for RuntimeInitialCreateExecutionInputs.UsePositionFromServer /// - the local-player-only interpolate gate consumed by /// RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition. /// public bool UsePositionFromServer => AutonomyLevel != FullAutonomyLevel; /// /// Retail CommandInterpreter::SetAutonomyLevel (pseudo-C /// 699542-699552): rejects any value above 2, otherwise commits. /// F5(a): retail's own setter ALSO sends SendAutonomyLevelEvent /// (pseudo-C 699550) after committing - this Runtime-only port has no /// outbound wire concept to carry that event today (autonomy level has /// no host caller yet). Any FUTURE host exposure of this setter (e.g. a /// debug/admin command) MUST also send the equivalent outbound event - /// do not port only the field write. /// public bool TrySetAutonomyLevel(uint level) { ObjectDisposedException.ThrowIf(_disposed, this); if (level > FullAutonomyLevel) return false; Volatile.Write(ref _autonomyLevel, level); return true; } public RuntimeCharacterOwnershipSnapshot CaptureOwnership() { int favoriteCount = 0; for (int tab = 0; tab < 8; tab++) favoriteCount += Spellbook.GetFavorites(tab).Count; int vitalCount = 0; foreach (LocalPlayerState.VitalKind kind in Enum.GetValues()) { if (LocalPlayer.Get(kind) is not null) vitalCount++; } int attributeCount = 0; foreach (LocalPlayerState.AttributeKind kind in Enum.GetValues()) { if (LocalPlayer.GetAttribute(kind) is not null) attributeCount++; } PropertyBundle properties = LocalPlayer.Properties; int propertyCount = properties.Bools.Count + properties.Ints.Count + properties.Int64s.Count + properties.Floats.Count + properties.Strings.Count + properties.DataIds.Count + properties.InstanceIds.Count; RuntimeCharacterOptionsSnapshot options = Options.Snapshot; return new RuntimeCharacterOwnershipSnapshot( _disposed, _internalSubscriptionsAttached, Spellbook.LearnedCount, Spellbook.ActiveCount, Spellbook.DesiredComponents.Count, favoriteCount, vitalCount, attributeCount, LocalPlayer.Skills.Count, LocalPlayer.Positions.Count, propertyCount, options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1 && options.Options2 == RuntimeCharacterOptionsState.DefaultOptions2, MovementSkills.RunSkill == -1 && MovementSkills.JumpSkill == -1 && MovementSkills.Burden == 0f && MovementSkills.CurrentStamina == -1 && MovementSkills.OwnPwdBitfield == 0u && MovementSkills.PlayerKillerStatus == -1 && MovementSkills.LastPkAttackTimestamp is null && _runSkillBase == -1 && _jumpSkillBase == -1 && _movementSkillAugmentations == default, AutonomyLevel == FullAutonomyLevel); } /// /// Campaign P Slice P1 (2026-07-30): stores the pre-EnchantSkill /// base run/jump skill (PlayerDescription's formulaBonus+init+ranks) /// and pushes the augmentation/vitae/enchantment-adjusted result into /// — the SAME call shape /// LiveSessionEventRouter's pre-P1 onSkillsUpdated callback /// already used (MovementSkills.Update(runSkill, jumpSkill)), now /// routed through the retail CEnchantmentRegistry::EnchantSkill /// chain. A value < 0 leaves that half's base untouched (matches /// 's own "don't touch" /// convention for a missing half). /// public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase) { ObjectDisposedException.ThrowIf(_disposed, this); if (runSkillBase >= 0) _runSkillBase = runSkillBase; if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase; RecomputeMovementSkills(); } /// /// Installs the player-quality augmentation terms consumed by retail /// CACQualities::InqRunRate/InqJumpVelocity. The object /// table remains authoritative for live PropertyInt updates; Runtime /// retains only this immutable derived snapshot. /// public void UpdateMovementSkillAugmentations( PlayerSkillMath.AugmentationBonuses augmentations) { ObjectDisposedException.ThrowIf(_disposed, this); if (_movementSkillAugmentations == augmentations) return; _movementSkillAugmentations = augmentations; RecomputeMovementSkills(); } /// /// Re-derives the adjusted run/jump skill from the stored base plus the /// CURRENT spellbook state (vitae + skill enchantments) — matching /// retail's CEnchantmentRegistry::EnchantSkill (0x005947b0): /// vitae multiplies first, then matching Skill-flagged mult/add /// enchantments, floored to 0 below 0.5, truncated to int. Fires on /// every base push AND on every /// notification (a vitae/buff change alone must move the produced rate /// without a fresh PlayerDescription). /// private void RecomputeMovementSkills() { int run = _runSkillBase >= 0 ? CalculateMovementSkill(_runSkillBase, RunSkillId) : -1; int jump = _jumpSkillBase >= 0 ? CalculateMovementSkill(_jumpSkillBase, JumpSkillId) : -1; // #266 apparatus (permanent, low-volume — fires only on skill-base or // enchantment changes, the [snap] class): the full stat-chain state at // each recompute. If vitae is active but runMod prints 1.0, the break // is in GetSkillMod's record handling; if runMod is right but the // felt speed doesn't change, the break is downstream of MovementSkills. EnchantmentMath.VitalMod runMod = Spellbook.GetSkillMod(RunSkillId); int activeCount = System.Linq.Enumerable.Count(Spellbook.ActiveEnchantments); System.Console.WriteLine( System.FormattableString.Invariant( $"[stat-chain] base run={_runSkillBase} jump={_jumpSkillBase} runMod={runMod.Multiplier:F4}x+{runMod.Additive:F1} -> eff run={run} jump={jump} (activeEnchantments={activeCount})")); MovementSkills.Update(run, jump); } private int CalculateMovementSkill(int baseSkill, uint skillId) { EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId); float vitae = EnchantmentMath.GetVitaeMultiplier( Spellbook.ActiveEnchantments); uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u; return PlayerSkillMath.Calculate( baseSkill, skillId, advancementClass, _movementSkillAugmentations, mod, vitae).EffectiveLevel; } private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills(); /// /// Installs immutable DAT metadata without transferring its ownership to /// Runtime. The content host may install one table after portal.dat opens. /// public void InstallSpellMetadata(SpellTable spellTable) { ObjectDisposedException.ThrowIf(_disposed, this); Spellbook.InstallMetadata(spellTable); } public void ResetSpellbook() { ObjectDisposedException.ThrowIf(_disposed, this); Spellbook.Clear(); } public void ResetLocalPlayer() { ObjectDisposedException.ThrowIf(_disposed, this); LocalPlayer.Clear(); } /// /// Apply retail's local favorite insertion before sending the matching /// character event. /// public bool TryAddFavorite( int tabIndex, int position, uint spellId, Action publishOutbound) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(publishOutbound); if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u) return false; Spellbook.SetFavorite(tabIndex, position, spellId); publishOutbound(); return true; } public bool TryRemoveFavorite( int tabIndex, uint spellId, Action publishOutbound) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(publishOutbound); if ((uint)tabIndex >= 8u || spellId == 0u) return false; Spellbook.RemoveFavorite(tabIndex, spellId); publishOutbound(); return true; } /// /// Apply and publish a spellbook filter only when it differs, matching /// gmSpellbookUI::UpdateFilter @ 0x0048B5E0. /// public void SetSpellbookFilter( uint filters, Action publishOutbound) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(publishOutbound); if (Spellbook.SpellbookFilters == filters) return; Spellbook.SetSpellbookFilters(filters); publishOutbound(); } /// /// Retail publishes the desired-component event before changing its local /// PlayerModule table. /// public bool TrySetDesiredComponent( uint componentId, uint amount, Action publishOutbound) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(publishOutbound); if (componentId == 0u || amount > 5000u) return false; try { publishOutbound(); } finally { Spellbook.SetDesiredComponent(componentId, amount); } return true; } public void ClearDesiredComponents(Action publishOutbound) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(publishOutbound); try { publishOutbound(); } finally { Spellbook.ClearDesiredComponents(); } } /// /// Clears both coupled owners while retaining every failed suffix for a /// retry. State mutation happens before the existing synchronous /// invalidation callbacks, so retrying is safe and convergent. /// public void ResetSession() { ObjectDisposedException.ThrowIf(_disposed, this); List? failures = null; Try(Spellbook.Clear, ref failures); Try(LocalPlayer.Clear, ref failures); Try(Options.ResetSession, ref failures); _runSkillBase = -1; _jumpSkillBase = -1; _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); if (failures is not null) { throw new AggregateException( "Runtime character state did not converge during reset.", failures); } } public void Dispose() { if (_disposed) return; List? failures = null; try { // Do not call ResetSession as one opaque step here. Terminal // disposal must run every suffix even when an external UI observer // throws from one Core owner's synchronous clear notification. Try(Spellbook.Clear, ref failures); Try(LocalPlayer.Clear, ref failures); Try(Options.ResetSession, ref failures); _runSkillBase = -1; _jumpSkillBase = -1; _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); } finally { Spellbook.StateChanged -= OnSpellbookChanged; Spellbook.EnchantmentsChanged -= OnEnchantmentsChangedForMovement; LocalPlayer.Changed -= OnVitalChanged; LocalPlayer.AttributeChanged -= OnAttributeChanged; LocalPlayer.CharacterChanged -= OnCharacterChanged; _internalSubscriptionsAttached = false; _disposed = true; } if (failures is not null) { throw new AggregateException( "Runtime character state did not converge during disposal.", failures); } } private void OnSpellbookChanged() => Interlocked.Increment(ref _spellbookRevision); private void OnVitalChanged(LocalPlayerState.VitalKind _) => Interlocked.Increment(ref _characterRevision); private void OnAttributeChanged(LocalPlayerState.AttributeKind _) => Interlocked.Increment(ref _characterRevision); private void OnCharacterChanged() => Interlocked.Increment(ref _characterRevision); private static void Try(Action action, ref List? failures) { try { action(); } catch (Exception error) { (failures ??= []).Add(error); } } private sealed class CharacterView(RuntimeCharacterState owner) : IRuntimeCharacterView { public RuntimeCharacterSnapshot Snapshot => new( Interlocked.Read(ref owner._characterRevision), Interlocked.Read(ref owner._spellbookRevision), owner.Options.Snapshot, owner.MovementSkills.Snapshot, owner.Spellbook.LearnedSpells.Count, owner.Spellbook.ActiveEnchantments.Count(), owner.Spellbook.DesiredComponents.Count, owner.LocalPlayer.Skills.Count, owner.Spellbook.SpellbookFilters); public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital) { if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind) || owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind) is not LocalPlayerState.VitalSnapshot current) { vital = default; return false; } vital = new RuntimeVitalSnapshot( kind, current.Ranks, current.Start, current.Xp, current.Current, owner.LocalPlayer.GetMaxApprox( (LocalPlayerState.VitalKind)kind) ?? 0u); return true; } public bool TryGetAttribute( int kind, out RuntimeAttributeSnapshot attribute) { if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind) || owner.LocalPlayer.GetAttribute( (LocalPlayerState.AttributeKind)kind) is not LocalPlayerState.AttributeSnapshot current) { attribute = default; return false; } attribute = new RuntimeAttributeSnapshot( kind, current.Ranks, current.Start, current.Xp, current.Current); return true; } public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill) { if (owner.LocalPlayer.GetSkill(skillId) is not LocalPlayerState.SkillSnapshot current) { skill = default; return false; } skill = new RuntimeSkillSnapshot( current.SkillId, current.Ranks, current.Status, current.Xp, current.Init, current.Resistance, current.LastUsed, current.FormulaBonus, current.CurrentLevel); return true; } public bool KnowsSpell(uint spellId) => owner.Spellbook.Knows(spellId); public bool TryGetFavorite( int tabIndex, int position, out uint spellId) { IReadOnlyList favorites = owner.Spellbook.GetFavorites(tabIndex); if ((uint)position >= (uint)favorites.Count) { spellId = 0u; return false; } spellId = favorites[position]; return true; } public bool TryGetDesiredComponent( uint componentId, out uint amount) => owner.Spellbook.DesiredComponents.TryGetValue( componentId, out amount); } } public readonly record struct RuntimeCharacterOptionsSnapshot( uint Options1, uint Options2, long Revision) { public bool DragItemOnPlayerOpensSecureTrade => (Options1 & (uint)PlayerDescriptionParser.CharacterOptions1 .DragItemOnPlayerOpensSecureTrade) != 0u; } /// /// Canonical session-owned copy of retail's two character-option bitfields. /// PlayerModule::PlayerModule @ 0x005D51F0 installs the defaults. /// Runtime reset restores the equivalent fresh-player-module state because /// one Runtime owner survives across graphical and no-window sessions. /// public sealed class RuntimeCharacterOptionsState { public const uint DefaultOptions1 = (uint)PlayerDescriptionParser.CharacterOptions1.Default; public const uint DefaultOptions2 = 0x00948700u; private uint _options1 = DefaultOptions1; private uint _options2 = DefaultOptions2; private long _revision; public uint Options1 => Volatile.Read(ref _options1); public uint Options2 => Volatile.Read(ref _options2); public long Revision => Interlocked.Read(ref _revision); public RuntimeCharacterOptionsSnapshot Snapshot => new(_options1, _options2, Revision); public bool DragItemOnPlayerOpensSecureTrade => Snapshot.DragItemOnPlayerOpensSecureTrade; public void Replace(uint options1, uint options2) { Volatile.Write(ref _options1, options1); Volatile.Write(ref _options2, options2); Interlocked.Increment(ref _revision); } /// /// Set ONE character-option bit locally, by its linear /// CharacterOptionId (the same id carried on the wire by /// SetSingleCharacterOption (0x0005)). Retail's /// PlayerModule::SetHearGeneralChat @0x005D35C0 (and its five /// SetHear*Chat siblings) write the bit into this LOCAL copy /// FIRST, before the client ever notifies the server. CH4 /// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's @join/ /// @leave previously pushed only the wire message and left this /// state untouched, so /// kept refusing a room the player had just joined until the next /// PlayerDescription happened to arrive. Only the six /// ListenTo*Chat ids CharacterOptionId models are /// recognized here; any other id is a silent no-op — this state only /// tracks what the Turbine-chat membership gate needs, not a complete /// PlayerModule mirror. /// public void SetOptionBit(uint characterOptionId, bool value) { (bool isOptions1, uint mask) = characterOptionId switch { (uint)CharacterOptionId.ListenToAllegianceChat => (true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat), (uint)CharacterOptionId.ListenToGeneralChat => (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat), (uint)CharacterOptionId.ListenToTradeChat => (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat), (uint)CharacterOptionId.ListenToLFGChat => (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat), (uint)CharacterOptionId.ListenToRoleplayChat => (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat), (uint)CharacterOptionId.ListenToSocietyChat => (false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat), _ => (false, 0u), }; if (mask == 0u) return; if (isOptions1) { uint updated = value ? (Options1 | mask) : (Options1 & ~mask); Volatile.Write(ref _options1, updated); } else { uint updated = value ? (Options2 | mask) : (Options2 & ~mask); Volatile.Write(ref _options2, updated); } Interlocked.Increment(ref _revision); } public void ResetSession() { Volatile.Write(ref _options1, DefaultOptions1); Volatile.Write(ref _options2, DefaultOptions2); Interlocked.Increment(ref _revision); } } public readonly record struct RuntimeMovementSkillSnapshot( int RunSkill, int JumpSkill, long Revision, // Campaign P Slice P1 (2026-07-30): retail InqLoad-equivalent burden // ratio (0.0 unencumbered) and current stamina (-1 = unknown/don't-gate, // matching RunSkill/JumpSkill's own sentinel convention). float Burden = 0f, int CurrentStamina = -1, // TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own // PublicWeenieDesc._bitfield (0 = never pushed / no PK-relevant bits — // a no-op OR, matching every pre-P3 caller) and the raw // PlayerKillerStatus/LastPkAttackTimestamp pair §12b's PK-timer // jump-cost bump reads (-1 / null = never pushed). uint OwnPwdBitfield = 0u, int PlayerKillerStatus = -1, float? LastPkAttackTimestamp = null) { public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; } /// /// Server-authoritative run/jump/burden/stamina/PK-status values retained /// independently of any graphical movement controller. App applies this /// borrowed state whenever its presentation/physics controller exists or is /// rebuilt. Campaign P Slice P1 (2026-07-30) extended this beyond run/jump /// skill to the full stat-coupled-movement input set (burden, current /// stamina) — see the pseudocode doc §9. Campaign P Slice P3 (2026-07-30) /// further added the player's own PWD bitfield (PK/PKLite/Impenetrable /// collision-exemption bits) and the PlayerKillerStatus/ /// LastPkAttackTimestamp pair (the PK-timer jump-cost bump) — the SAME /// reactive push seam, since both ride the player's own ClientObject /// property-update stream. RunSkill/JumpSkill arrive here ALREADY /// vitae/enchantment-adjusted by /// . /// public sealed class RuntimeMovementSkillState { private int _runSkill = -1; private int _jumpSkill = -1; private float _burden; private int _currentStamina = -1; private uint _ownPwdBitfield; private int _playerKillerStatus = -1; private float _lastPkAttackTimestamp; private bool _hasLastPkAttackTimestamp; private long _revision; public int RunSkill => Volatile.Read(ref _runSkill); public int JumpSkill => Volatile.Read(ref _jumpSkill); public float Burden => Volatile.Read(ref _burden); public int CurrentStamina => Volatile.Read(ref _currentStamina); public uint OwnPwdBitfield => Volatile.Read(ref _ownPwdBitfield); public int PlayerKillerStatus => Volatile.Read(ref _playerKillerStatus); public float? LastPkAttackTimestamp => Volatile.Read(ref _hasLastPkAttackTimestamp) ? Volatile.Read(ref _lastPkAttackTimestamp) : null; public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0; public long Revision => Interlocked.Read(ref _revision); public RuntimeMovementSkillSnapshot Snapshot => new( _runSkill, _jumpSkill, Revision, _burden, _currentStamina, OwnPwdBitfield, PlayerKillerStatus, LastPkAttackTimestamp); public void Update(int runSkill, int jumpSkill) { bool changed = false; if (runSkill >= 0 && RunSkill != runSkill) { Volatile.Write(ref _runSkill, runSkill); changed = true; } if (jumpSkill >= 0 && JumpSkill != jumpSkill) { Volatile.Write(ref _jumpSkill, jumpSkill); changed = true; } if (changed) Interlocked.Increment(ref _revision); } /// /// Pushes a fresh retail InqLoad-equivalent burden ratio (Strength /// + augmentation property 0xE6 + EncumbranceVal property 5 — the same /// inputs the burden HUD already assembles). Any value, including 0, /// is a real reading. /// public void UpdateBurden(float burden) { if (Burden == burden) return; Volatile.Write(ref _burden, burden); Interlocked.Increment(ref _revision); } /// /// Pushes the current-stamina vital reading. Any non-negative value, /// including 0 (exhausted — zeroes the effective run/jump skill), is /// real; pass a negative value only to restore the "unknown" sentinel. /// public void UpdateStamina(int currentStamina) { if (CurrentStamina == currentStamina) return; Volatile.Write(ref _currentStamina, currentStamina); Interlocked.Increment(ref _revision); } /// /// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's own /// raw PublicWeenieDesc._bitfield — the PWD wire bit-space /// /// decodes. Fires on the SAME ClientObject add/update events /// already reacts to (the player's own /// PublicWeenieBitfield lives on the same row). /// public void UpdateOwnPwdBitfield(uint bitfield) { if (OwnPwdBitfield == bitfield) return; Volatile.Write(ref _ownPwdBitfield, bitfield); Interlocked.Increment(ref _revision); } /// /// TS-23 §12b: pushes the raw PlayerKillerStatus /// (PropertyInt 0x86) / LastPkAttackTimestamp /// (PropertyFloat 0x91) pair feeding /// CACQualities::JumpStaminaCost's 20-second PK-timer bump. A /// negative restores "never /// pushed" (matches 's own sentinel /// convention); is null /// when the property is absent. /// public void UpdatePlayerKillerStatus(int playerKillerStatus, float? lastPkAttackTimestamp) { bool changed = false; if (PlayerKillerStatus != playerKillerStatus) { Volatile.Write(ref _playerKillerStatus, playerKillerStatus); changed = true; } bool hasTimestamp = lastPkAttackTimestamp.HasValue; float timestamp = lastPkAttackTimestamp ?? 0f; if (Volatile.Read(ref _hasLastPkAttackTimestamp) != hasTimestamp || (hasTimestamp && Volatile.Read(ref _lastPkAttackTimestamp) != timestamp)) { Volatile.Write(ref _lastPkAttackTimestamp, timestamp); Volatile.Write(ref _hasLastPkAttackTimestamp, hasTimestamp); changed = true; } if (changed) Interlocked.Increment(ref _revision); } public void ResetSession() { Volatile.Write(ref _runSkill, -1); Volatile.Write(ref _jumpSkill, -1); Volatile.Write(ref _burden, 0f); Volatile.Write(ref _currentStamina, -1); Volatile.Write(ref _ownPwdBitfield, 0u); Volatile.Write(ref _playerKillerStatus, -1); Volatile.Write(ref _lastPkAttackTimestamp, 0f); Volatile.Write(ref _hasLastPkAttackTimestamp, false); Interlocked.Increment(ref _revision); } }