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

@ -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();
}