using AcDream.Core.Player; using AcDream.Core.Net.Messages; using AcDream.Core.Spells; using AcDream.Core.Items; 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) { 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; } /// /// 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; private bool _disposed; private long _characterRevision; private long _spellbookRevision; private bool _internalSubscriptionsAttached; /// /// 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; 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; 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); } /// /// Campaign P Slice P1 (2026-07-30): stores the pre-EnchantSkill /// base run/jump skill (PlayerDescription's formulaBonus+init+ranks) /// and pushes the 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(); } /// /// 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 ? ApplySkillEnchantments(_runSkillBase, RunSkillId) : -1; int jump = _jumpSkillBase >= 0 ? ApplySkillEnchantments(_jumpSkillBase, JumpSkillId) : -1; MovementSkills.Update(run, jump); } private int ApplySkillEnchantments(int baseSkill, uint skillId) { EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId); float adjusted = baseSkill * mod.Multiplier + mod.Additive; // CEnchantmentRegistry::EnchantSkill pc 416240: floor to 0 below // 0.5, then truncate (retail _ftol2, a C-style cast). if (adjusted < 0.5f) adjusted = 0f; return (int)adjusted; } 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; 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; 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); } 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); } }