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:
parent
3a4782048e
commit
9355ddcec6
20 changed files with 1714 additions and 74 deletions
43
src/AcDream.Core/Physics/EncumbranceSystem.cs
Normal file
43
src/AcDream.Core/Physics/EncumbranceSystem.cs
Normal 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 <= 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 <= 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);
|
||||
}
|
||||
98
src/AcDream.Core/Physics/MovementSystem.cs
Normal file
98
src/AcDream.Core/Physics/MovementSystem.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue