feat(physics): Campaign P P1 - stat-coupled movement (burden/stamina/vitae)

Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain
(named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so
PlayerWeenie's run rate, jump height, jump permission, and jump stamina
cost are real functions of burden, current stamina, and vitae/skill
enchantments instead of stubs.

Core:
- New EncumbranceSystem.cs (delegates to the already-verified
  BurdenMath formulas — one source of truth for the burden HUD and
  movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/
  JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced
  where BN dropped the general-case arithmetic entirely).
- PlayerWeenie rewritten as the CACQualities-shaped composition:
  CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by
  plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost
  returns the real ceil((load+0.5)*power*8+2) cost and always affords
  it (matches decomp — retail's own function never refuses; "weak"
  jump comes entirely from the stamina==0 skill-zeroing gate inside
  InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a
  null="unknown, don't gate" sentinel preserving every pre-P1 test.
- EnchantmentMath.GetMod gained an optional StatModType flag filter
  (GetSkillMod convenience wrapper) so the SAME vitae/family-stacking
  machinery already used for vital-max buffs now also answers "what's
  the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the
  M3 active-enchantment state, not a new engine.

Runtime:
- RuntimeCharacterState now stores the pre-EnchantSkill base run/jump
  skill and recomputes the adjusted value (vitae first, then matching
  Skill-flagged buffs, floor 0.5, truncate) on every base push AND on
  every Spellbook.EnchantmentsChanged notification — a vitae change
  alone moves the produced rate without a fresh PlayerDescription.
- RuntimeMovementSkillState extended with Burden/CurrentStamina
  (RuntimeMovementSkillProjection.ApplyTo pushes both through the
  existing seam); LiveSessionEventRouter recomputes burden from the
  same Strength+aug-property+EncumbranceVal inputs the burden HUD
  already assembles (reacting to the same ClientObjectTable events)
  and pushes current stamina from LocalPlayerState vital updates.
- Wires the previously dead-lettered ReportExhaustion() R3-W4 seam:
  LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re-
  applies the current snapshot to the live controller and forces an
  immediate movement re-evaluation on any skill/burden/stamina change.

Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no
vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus
properties + the stamina-buff-adjusts-local-copy nuance, deliberately
out of the bounded "run/jump query path only" scope) and UN-8 (the
CanJump x87 polarity call, flagged for a future Ghidra MCP
confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to
cover JumpStaminaCost's new pk parameter, hardcoded false pending P3.

Full pseudocode + retail citations + the vitae/skill-level finding in
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md.

Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips,
App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug-
only flake in LandblockBuildOriginTests reproduces on the pre-P1
baseline and passes in Release; not touched here.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-30 08:18:06 +02:00
parent 3a4782048e
commit 9355ddcec6
20 changed files with 1714 additions and 74 deletions

View file

@ -0,0 +1,43 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>EncumbranceSystem</c> (named-retail decomp
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> pc 256393),
/// the pure load/capacity math consumed by <see cref="MovementSystem"/> and
/// <see cref="PlayerWeenie"/>'s <c>InqLoad</c>-equivalent chain.
///
/// <para>
/// These are the SAME three formulas <c>AcDream.Core.Items.BurdenMath</c>
/// already ports (verified against the identical retail addresses, used by
/// the burden HUD / character sheet / indicator bar). Rather than re-derive
/// them a second time under a different name, this class delegates —
/// P1 (2026-07-30, Campaign P) needs the retail-named surface next to
/// <see cref="MovementSystem"/> for citation clarity, but a single formula
/// implementation keeps the burden HUD and movement physics from drifting
/// apart. See <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c>
/// §2 and §6.
/// </para>
/// </summary>
public static class EncumbranceSystem
{
/// <summary>
/// <c>EncumbranceSystem::EncumbranceCapacity</c> 0x004fcc00:
/// <c>strength &lt;= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
/// </summary>
public static int EncumbranceCapacity(int strength, int aug) =>
AcDream.Core.Items.BurdenMath.EncumbranceCapacity(strength, aug);
/// <summary>
/// <c>EncumbranceSystem::Load</c> 0x004fcc40: <c>burden / capacity</c>
/// (1.0 = at capacity). Returns 0 when capacity &lt;= 0 (no-data).
/// </summary>
public static float Load(int capacity, int burden) =>
AcDream.Core.Items.BurdenMath.LoadRatio(capacity, burden);
/// <summary>
/// <c>EncumbranceSystem::LoadMod</c> 0x004fcc70: full effectiveness
/// through 100% load, linear falloff to zero at 200%, zero above it.
/// </summary>
public static float LoadMod(float load) =>
AcDream.Core.Items.BurdenMath.LoadModifier(load);
}

View file

@ -0,0 +1,98 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>MovementSystem</c> (named-retail decomp pc 695958+): the pure
/// formula layer <c>PlayerWeenie</c> (the CACQualities-shaped composition)
/// calls once burden, skill, and stamina inputs are assembled. See
/// <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c> §6.
///
/// <para>
/// <b>x87-mush disclosure:</b> <see cref="GetRunRate"/>'s general-case
/// arithmetic and <see cref="JumpStaminaCost"/>'s <c>pk!=0</c> branch are
/// entirely dropped by the BN decompiler (not partially garbled — the
/// operand expressions never survive translation, unlike the polarity-only
/// ambiguities elsewhere in this port). Both are cross-referenced against
/// <c>references/ACE/Source/ACE.Server/Physics/Animation/MovementSystem.cs</c>,
/// which already matched this exact acdream port's PRE-P1 code
/// (<c>PlayerWeenie.GetRunRate</c>/<c>GetJumpHeight</c> already cited "decompiled
/// + ACE MovementSystem" before this slice) — no behavior change for the
/// formulas already live; only the retail-named surface and the 3rd/4th
/// <c>scaling</c> parameters (every known call site passes <c>1f</c>) are new.
/// </para>
/// </summary>
public static class MovementSystem
{
/// <summary>
/// <c>MovementSystem::GetRunRate</c> 0x006b0950. Retail-verified 800-skill
/// cap (<c>InqMaxRunRate</c> passes skill=9999 to reach this cap); general
/// case ACE-cross-referenced (BN dropped the arithmetic, see class doc).
/// </summary>
public static float GetRunRate(float burden, int runSkill, float scaling = 1f)
{
if (runSkill >= 800)
return 18f / 4f;
float loadMod = EncumbranceSystem.LoadMod(burden);
return ((loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / scaling) / 4f;
}
/// <summary>
/// <c>MovementSystem::GetJumpHeight</c> 0x006b09b0. Fully readable except
/// the extent-clamp micro-branch (x87 mush, ACE's <c>Math.Clamp(power,0,1)</c>
/// is the tiebreaker — matches the pre-P1 acdream port unchanged).
/// </summary>
public static float GetJumpHeight(
float burden,
int jumpSkill,
float power,
float scaling = 1f)
{
power = Math.Clamp(power, 0f, 1f);
float loadMod = EncumbranceSystem.LoadMod(burden);
float result = loadMod
* ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f)
* power
/ scaling;
return result < 0.35f ? 0.35f : result;
}
/// <summary>
/// <c>MovementSystem::JumpStaminaCost</c> 0x006b0a40. The <c>pk==0</c>
/// branch is fully readable: <c>ceil((load + 0.5) * power * 8 + 2)</c> —
/// note <c>load</c> (not <c>power</c>) carries the <c>+0.5</c>; the
/// campaign plan's shorthand had the operands swapped (see pseudocode
/// doc §6). The <c>pk!=0</c> branch is entirely dropped by BN; ACE's
/// <c>(power+1.0)*100.0</c> is the tiebreaker. <paramref name="pk"/> is
/// hardcoded <c>false</c> at every P1 call site pending TS-23
/// (PlayerKillerStatus parsing, Campaign P Slice P3) — ported here for
/// signature completeness only.
/// </summary>
public static int JumpStaminaCost(float power, float burden, bool pk)
{
if (pk)
return (int)((power + 1.0f) * 100.0f);
return (int)Math.Ceiling((burden + 0.5f) * power * 8f + 2f);
}
/// <summary>
/// <c>MovementSystem::GetJumpPower</c> — the algebraic inverse of
/// <see cref="JumpStaminaCost"/>, solving for the extent affordable at a
/// given stamina. Present in ACE (uncommented, live utility) but its
/// retail call site is the charge-power-meter UI outside
/// <c>CMotionInterp</c> (0x0056afac, out of R3/P1 scope — see
/// <c>ChargeJump</c>'s doc comment in <c>MotionInterpreter.cs</c>). Not
/// consumed by P1; ported for signature completeness and to leave the
/// formula available for the charge-meter follow-up without a second
/// decomp pass.
/// </summary>
public static float GetJumpPower(uint stamina, float burden, bool pk)
{
if (pk)
return stamina / 100.0f - 1.0f;
return (stamina - 2.0f) / (burden * 8.0f + 4.0f);
}
}

View file

@ -1,18 +1,56 @@
namespace AcDream.Core.Physics;
/// <summary>
/// IWeenieObject implementation for the local player. Provides skill-based
/// run rate and jump velocity calculations.
/// IWeenieObject implementation for the local player — the C# analogue of
/// retail's <c>CACQualities</c> "qualities DB" composition (named-retail
/// decomp pc 412901-414050; ACCWeenieObject's own CanJump/JumpStaminaCost/
/// InqRunRate/InqJumpVelocity/InqMaxRunRate are thin delegations to exactly
/// this object, gated on <c>IsThePlayer()</c> — pc 406512+, confirming this
/// query family only ever reaches the LOCAL player's weenie).
///
/// Formulas from decompiled acclient.exe, cross-referenced against
/// ACE MovementSystem.GetRunRate and MovementSystem.GetJumpHeight.
/// <para>
/// Campaign P Slice P1 (2026-07-30): burden, current stamina, and the
/// vitae/enchantment-adjusted run/jump skill are now real, pushed inputs —
/// see <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c>.
/// <c>_runSkill</c>/<c>_jumpSkill</c> arrive ALREADY <c>EnchantSkill</c>-adjusted
/// (vitae + skill enchantments folded in by
/// <c>AcDream.Runtime.Gameplay.RuntimeCharacterState.RecomputeMovementSkills</c>)
/// — this class stays a pure formula consumer with no Spellbook/enchantment
/// dependency, matching retail's OWN split between <c>CACQualities</c> (the
/// query surface) and <c>CEnchantmentRegistry</c> (the buff aggregator it
/// calls into internally).
/// </para>
///
/// <para>
/// Formulas from decompiled acclient.exe (<see cref="MovementSystem"/> /
/// <see cref="EncumbranceSystem"/>), cross-referenced against ACE
/// <c>MovementSystem.GetRunRate</c>/<c>GetJumpHeight</c>.
/// </para>
/// </summary>
public sealed class PlayerWeenie : IWeenieObject
{
/// <summary>
/// Retail <c>CACQualities::CanJump</c>'s hard burden gate (0x00591b50,
/// pc 412907) — x87 mush, polarity resolved by domain plausibility
/// (register row UN-8; Ghidra MCP was unavailable to confirm). Chosen
/// to coincide with <see cref="EncumbranceSystem.LoadMod"/>'s own floor.
/// </summary>
public const float CanJumpLoadThreshold = 2.0f;
private int _runSkill;
private int _jumpSkill;
private float _burden;
/// <summary>
/// Retail <c>AttributeCache::InqAttribute2nd(ATTR2ND_STAMINA=4)</c>'s
/// current-stamina reading, consulted by <c>InqRunRate</c>/
/// <c>InqJumpVelocity</c> to zero the effective skill when exhausted
/// (pc 413824-413898, 413902-413979). <c>null</c> = never pushed
/// (matches every pre-P1 caller/test — no gating, today's behavior
/// unchanged); any non-negative value including 0 is a real reading.
/// </summary>
private uint? _currentStamina;
public PlayerWeenie(int runSkill = 0, int jumpSkill = 0, float burden = 0f)
{
_runSkill = runSkill;
@ -26,74 +64,91 @@ public sealed class PlayerWeenie : IWeenieObject
_jumpSkill = jumpSkill;
}
/// <summary>
/// Pushes the retail <c>InqLoad</c>-equivalent burden/capacity ratio
/// (0.0 unencumbered .. ~3.0 severely overloaded). Runtime computes this
/// from Strength + augmentation property 0xE6 + EncumbranceVal property
/// 5 (the same inputs <c>IndicatorBarController.UpdateBurden</c> already
/// assembles) and pushes it — see the pseudocode doc §9.
/// </summary>
public void SetBurden(float burden) => _burden = burden;
/// <summary>
/// Pushes the current-stamina reading feeding the zero-skill gate in
/// <see cref="InqRunRate"/>/<see cref="InqJumpVelocity"/>. Pass
/// <c>null</c> to return to "unknown, don't gate" (matches construction
/// default).
/// </summary>
public void SetStamina(uint? currentStamina) => _currentStamina = currentStamina;
public bool InqRunRate(out float rate)
{
rate = GetRunRate(_burden, _runSkill);
int effectiveSkill = _currentStamina == 0 ? 0 : _runSkill;
rate = MovementSystem.GetRunRate(_burden, effectiveSkill);
return true;
}
public bool InqJumpVelocity(float extent, out float vz)
{
float height = GetJumpHeight(_burden, _jumpSkill, extent);
int effectiveSkill = _currentStamina == 0 ? 0 : _jumpSkill;
float height = MovementSystem.GetJumpHeight(_burden, effectiveSkill, extent);
vz = MathF.Sqrt(height * 19.6f);
return true;
}
public bool CanJump(float extent) => true; // burden/stamina checks deferred
/// <summary>
/// Retail <c>CACQualities::CanJump</c> (0x00591b50): refuses only past
/// <see cref="CanJumpLoadThreshold"/> (200% load) — see the class doc's
/// UN-8 note. TS-5 retired: this was previously an unconditional
/// <c>true</c>.
/// </summary>
public bool CanJump(float extent) => _burden < CanJumpLoadThreshold;
/// <summary>
/// R3-W3 (W0-pins.md A3): the local player's weenie is THE player.
/// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// dual-dispatch gate — no consumer yet in W3.
/// dual-dispatch gate.
/// </summary>
public bool IsThePlayer() => true;
/// <summary>
/// TS-5 (extended): stamina cost gating deferred pending stat plumbing —
/// always affordable, cost 0. Matches <see cref="CanJump"/>'s existing
/// always-true stance.
/// Retail <c>CACQualities::JumpStaminaCost</c> (0x00591b90, pc 412949,
/// FULLY READABLE): computes the real cost via
/// <see cref="MovementSystem.JumpStaminaCost"/> and returns <c>true</c>
/// unconditionally (once burden is knowable, which it always is for the
/// local player) — retail's own function never exercises the "can't
/// afford" false path; see the pseudocode doc §4/§7. TS-5 retired: this
/// was previously a zero-cost stub. <c>pk</c> is hardcoded <c>false</c>
/// pending TS-23 (PlayerKillerStatus parsing, Campaign P Slice P3).
/// </summary>
public bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
cost = MovementSystem.JumpStaminaCost(extent, _burden, pk: false);
return true;
}
/// <summary>
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
/// Capped at 4.5 when runSkill >= 800.
/// Source: decompiled + ACE MovementSystem.GetRunRate
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4.
/// Capped at 4.5 when runSkill >= 800. Thin forwarder to
/// <see cref="MovementSystem.GetRunRate"/> — kept for source
/// compatibility with existing golden-value tests.
/// </summary>
public static float GetRunRate(float burden, int runSkill)
{
if (runSkill >= 800) return 18f / 4f;
float loadMod = GetBurdenMod(burden);
return (loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / 4f;
}
public static float GetRunRate(float burden, int runSkill) =>
MovementSystem.GetRunRate(burden, runSkill);
/// <summary>
/// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent
/// Clamped to minimum 0.35m.
/// Source: decompiled + ACE MovementSystem.GetJumpHeight
/// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent,
/// clamped to minimum 0.35m. Thin forwarder to
/// <see cref="MovementSystem.GetJumpHeight"/> — kept for source
/// compatibility with existing golden-value tests.
/// </summary>
public static float GetJumpHeight(float burden, int jumpSkill, float extent)
{
extent = Math.Clamp(extent, 0f, 1f);
float loadMod = GetBurdenMod(burden);
float height = loadMod * ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f) * extent;
return MathF.Max(height, 0.35f);
}
public static float GetJumpHeight(float burden, int jumpSkill, float extent) =>
MovementSystem.GetJumpHeight(burden, jumpSkill, extent);
/// <summary>
/// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at 200%.
/// Source: decompiled + ACE EncumbranceSystem.GetBurdenMod
/// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at
/// 200%. Thin forwarder to <see cref="EncumbranceSystem.LoadMod"/> —
/// kept for source compatibility with existing golden-value tests.
/// </summary>
public static float GetBurdenMod(float burden)
{
if (burden < 1f) return 1f;
if (burden < 2f) return 2f - burden;
return 0f;
}
public static float GetBurdenMod(float burden) => EncumbranceSystem.LoadMod(burden);
}

View file

@ -76,11 +76,20 @@ public static class EnchantmentMath
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
/// <param name="statKey">Target stat key (ACE
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
/// 3=MaxStamina, 5=MaxMana).</param>
/// 3=MaxStamina, 5=MaxMana — or a Skill id when
/// <paramref name="requiredStatModTypeFlag"/> is
/// <see cref="EnchantmentTypeFlag.Skill"/>).</param>
/// <param name="requiredStatModTypeFlag">When set, a record's
/// <c>StatModType</c> must carry this flag bit to be considered a
/// candidate — disambiguates namespaces that share numeric keys (e.g.
/// vital key 5=MaxMana vs skill id 5). <c>null</c> (default) preserves
/// the original vitals behavior with no type check, unchanged from
/// before Campaign P.</param>
public static VitalMod GetMod(
IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table,
uint statKey)
uint statKey,
uint? requiredStatModTypeFlag = null)
{
// Family-stacking: bucket the active enchantments by Family and
// keep the strongest one per bucket (the one with the largest
@ -129,8 +138,20 @@ public static class EnchantmentMath
continue;
}
// Campaign P (2026-07-30): an optional type-flag gate
// disambiguates numeric-key collisions across namespaces (e.g.
// vital key 5=MaxMana vs skill id 5) — mirrors retail's
// CullEnchantmentsFromList `category` argument (2 for
// Attribute2nd, 0x10=Skill for EnchantSkill).
if (requiredStatModTypeFlag is uint typeFlag
&& (ench.StatModType is not uint recordType
|| (recordType & typeFlag) == 0))
{
continue;
}
// Multiplicative + Additive buffs filter by stat key —
// only those targeting the requested vital contribute.
// only those targeting the requested vital/skill contribute.
if (ench.StatModKey is not uint key || key != statKey) continue;
switch (ench.Bucket)
{
@ -147,6 +168,35 @@ public static class EnchantmentMath
: new VitalMod(multiplier, additive);
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — Skill-namespace convenience over
/// <see cref="GetMod"/>, matching retail <c>CEnchantmentRegistry::
/// EnchantSkill</c> (0x005947b0): vitae applies unconditionally (same
/// as vitals), multiplicative/additive skill buffs are filtered to
/// records whose <c>StatModType</c> carries
/// <see cref="EnchantmentTypeFlag.Skill"/> AND whose <c>StatModKey</c>
/// equals <paramref name="skillId"/> (ACE Skill enum ordinal — Run=24,
/// Jump=22). Scoped to the run/jump query path per the P1 plan; not a
/// general effective-skill engine.
/// </summary>
public static VitalMod GetSkillMod(
IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table,
uint skillId) =>
GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill);
/// <summary>
/// Retail <c>EnchantmentTypeFlags</c> bits relevant to disambiguating
/// <see cref="GetMod"/>'s <c>statKey</c> namespace (ACE
/// <c>ACE.Entity.Enum.EnchantmentTypeFlags</c>, cross-referenced —
/// StatModType is a bitfield the wire already carries per-enchantment).
/// </summary>
public static class EnchantmentTypeFlag
{
public const uint SecondAtt = 0x0000002u;
public const uint Skill = 0x0000010u;
}
/// <summary>
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
/// (verified against <c>docs/research/named-retail/acclient.h</c>

View file

@ -24,6 +24,7 @@ public sealed class Spellbook
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _skillModCache = new();
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray();
private readonly Dictionary<uint, uint> _desiredComponents = new();
@ -102,6 +103,29 @@ public sealed class Spellbook
return calculated;
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — combined vitae + skill-enchantment
/// buff modifier for a Skill id (ACE Skill enum ordinal — Run=24,
/// Jump=22), matching retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// (0x005947b0). Mirrors <see cref="GetVitalMod"/>'s caching shape;
/// consumed by <c>AcDream.Runtime.Gameplay.RuntimeCharacterState</c>'s
/// run/jump skill recompute.
/// </summary>
public EnchantmentMath.VitalMod GetSkillMod(uint skillId)
{
if (_skillModCache.TryGetValue(
skillId,
out EnchantmentMath.VitalMod cached))
{
return cached;
}
EnchantmentMath.VitalMod calculated =
EnchantmentMath.GetSkillMod(ActiveEnchantments, _table, skillId);
_skillModCache.Add(skillId, calculated);
return calculated;
}
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
public event Action<uint>? SpellLearned;
@ -356,6 +380,7 @@ public sealed class Spellbook
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
_vitalModCache.Clear();
_skillModCache.Clear();
foreach (ActiveEnchantmentRecord enchantment in enchantments)
UpsertManifestEnchantment(enchantment);
@ -430,6 +455,7 @@ public sealed class Spellbook
_activeById.Clear();
_enchantmentOrderByBucket.Clear();
_vitalModCache.Clear();
_skillModCache.Clear();
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
_desiredComponents.Clear();
_spellbookFilters = 0x3FFFu;
@ -448,6 +474,7 @@ public sealed class Spellbook
private void NotifyEnchantmentsChanged()
{
_vitalModCache.Clear();
_skillModCache.Clear();
EnchantmentsChanged?.Invoke();
StateChanged?.Invoke();
}