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
|
|
@ -275,25 +275,45 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_domain.Actions.Combat,
|
||||
_domain.Character,
|
||||
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
if (RuntimeMovementSkillProjection.ApplyTo(
|
||||
_domain.Character.MovementSkills,
|
||||
_player.Controller.Controller))
|
||||
{
|
||||
RuntimeMovementSkillSnapshot snapshot =
|
||||
_domain.Character.MovementSkills.Snapshot;
|
||||
_log(
|
||||
$"player: applied server skills " +
|
||||
$"run={snapshot.RunSkill} " +
|
||||
$"jump={snapshot.JumpSkill}");
|
||||
}
|
||||
},
|
||||
OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"),
|
||||
OnConfirmationRequest: request =>
|
||||
_ui.RetailUi?.HandleConfirmationRequest(request),
|
||||
OnConfirmationDone: done =>
|
||||
_ui.RetailUi?.HandleConfirmationDone(done),
|
||||
ClientTime: ClientTimerNow);
|
||||
ClientTime: ClientTimerNow,
|
||||
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
|
||||
// reactively re-apply to the live controller through the SAME
|
||||
// seam skills already used, then wire the previously-dead
|
||||
// ReportExhaustion() R3-W4 seam so movement re-evaluates
|
||||
// immediately (pseudocode doc §8/§9).
|
||||
OnMovementStatsUpdated: () => ApplyMovementStats("stats"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
|
||||
/// snapshot (skills/burden/stamina) to the live player controller and
|
||||
/// forces an immediate movement re-evaluation via
|
||||
/// <c>MotionInterpreter.ReportExhaustion</c> — the retail
|
||||
/// <c>CMotionInterp::ReportExhaustion</c> dual-dispatch re-apply, now
|
||||
/// wired to a real consumer (Campaign P Slice P1).
|
||||
/// </summary>
|
||||
private void ApplyMovementStats(string reason)
|
||||
{
|
||||
PlayerMovementController? controller = _player.Controller.Controller;
|
||||
if (!RuntimeMovementSkillProjection.ApplyTo(
|
||||
_domain.Character.MovementSkills,
|
||||
controller))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
controller!.Motion.ReportExhaustion();
|
||||
|
||||
RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot;
|
||||
_log(
|
||||
$"player: applied server movement {reason} "
|
||||
+ $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} "
|
||||
+ $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}");
|
||||
}
|
||||
|
||||
private LiveSessionCommandBindings CreateCommandBindings(
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -567,7 +567,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () =>
|
||||
Runtime.Clock.SimulationTimeSeconds),
|
||||
Runtime.Clock.SimulationTimeSeconds,
|
||||
// Campaign P Slice P1 (2026-07-30): headless bots re-apply
|
||||
// burden/stamina/skills at controller construction only
|
||||
// (HeadlessSessionWorldProjection.CreateController), same as
|
||||
// the pre-P1 OnSkillsUpdated: null pattern — no live
|
||||
// controller to reactively re-apply to mid-session here.
|
||||
OnMovementStatsUpdated: null),
|
||||
new LiveSocialSessionBindings(
|
||||
Runtime.CommunicationOwner.Chat,
|
||||
Runtime.CommunicationOwner.TurbineChat,
|
||||
|
|
|
|||
|
|
@ -891,6 +891,30 @@ public sealed class PlayerMovementController
|
|||
_weenie.SetSkills(runSkill, jumpSkill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): pushes the retail
|
||||
/// <c>InqLoad</c>-equivalent burden ratio computed by Runtime (Strength
|
||||
/// + augmentation property 0xE6 + EncumbranceVal property 5) into the
|
||||
/// player's <see cref="PlayerWeenie"/> — wires the previously-dead
|
||||
/// <c>PlayerWeenie.SetBurden</c> setter (TS-5 retired).
|
||||
/// </summary>
|
||||
public void SetCharacterBurden(float burden)
|
||||
{
|
||||
_weenie.SetBurden(burden);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): pushes the current-stamina vital
|
||||
/// reading. A negative value restores PlayerWeenie's "unknown, don't
|
||||
/// gate" sentinel; any non-negative value including 0 gates
|
||||
/// <c>InqRunRate</c>/<c>InqJumpVelocity</c>'s effective-skill-zeroing per
|
||||
/// retail's <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>.
|
||||
/// </summary>
|
||||
public void SetCharacterStamina(int currentStamina)
|
||||
{
|
||||
_weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
|
||||
/// — GameWindow binds the player sequencer's MotionDone seam to it so the
|
||||
|
|
|
|||
|
|
@ -43,11 +43,28 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
|||
/// </summary>
|
||||
public sealed class RuntimeCharacterState : IDisposable
|
||||
{
|
||||
/// <summary>ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5).</summary>
|
||||
public const uint RunSkillId = 24u;
|
||||
/// <summary>ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5).</summary>
|
||||
public const uint JumpSkillId = 22u;
|
||||
|
||||
private bool _disposed;
|
||||
private long _characterRevision;
|
||||
private long _spellbookRevision;
|
||||
private bool _internalSubscriptionsAttached;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): the pre-<c>EnchantSkill</c> base
|
||||
/// run/jump skill (formulaBonus+init+ranks, as parsed from
|
||||
/// PlayerDescription) — kept so <see cref="RecomputeMovementSkills"/>
|
||||
/// can re-derive the vitae/enchantment-adjusted value purely from a
|
||||
/// spellbook change, without waiting for a fresh skill push. -1 =
|
||||
/// unknown (mirrors <see cref="RuntimeMovementSkillState"/>'s own
|
||||
/// sentinel convention).
|
||||
/// </summary>
|
||||
private int _runSkillBase = -1;
|
||||
private int _jumpSkillBase = -1;
|
||||
|
||||
public RuntimeCharacterState(SpellTable? spellTable = null)
|
||||
{
|
||||
Spellbook = new Spellbook(spellTable);
|
||||
|
|
@ -56,6 +73,7 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
MovementSkills = new RuntimeMovementSkillState();
|
||||
View = new CharacterView(this);
|
||||
Spellbook.StateChanged += OnSpellbookChanged;
|
||||
Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement;
|
||||
LocalPlayer.Changed += OnVitalChanged;
|
||||
LocalPlayer.AttributeChanged += OnAttributeChanged;
|
||||
LocalPlayer.CharacterChanged += OnCharacterChanged;
|
||||
|
|
@ -117,9 +135,66 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
&& options.Options2
|
||||
== RuntimeCharacterOptionsState.DefaultOptions2,
|
||||
MovementSkills.RunSkill == -1
|
||||
&& MovementSkills.JumpSkill == -1);
|
||||
&& MovementSkills.JumpSkill == -1
|
||||
&& MovementSkills.Burden == 0f
|
||||
&& MovementSkills.CurrentStamina == -1
|
||||
&& _runSkillBase == -1
|
||||
&& _jumpSkillBase == -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
|
||||
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
|
||||
/// and pushes the vitae/enchantment-adjusted result into
|
||||
/// <see cref="MovementSkills"/> — the SAME call shape
|
||||
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
|
||||
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
|
||||
/// routed through the retail <c>CEnchantmentRegistry::EnchantSkill</c>
|
||||
/// chain. A value < 0 leaves that half's base untouched (matches
|
||||
/// <see cref="RuntimeMovementSkillState.Update"/>'s own "don't touch"
|
||||
/// convention for a missing half).
|
||||
/// </summary>
|
||||
public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (runSkillBase >= 0) _runSkillBase = runSkillBase;
|
||||
if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase;
|
||||
RecomputeMovementSkills();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-derives the adjusted run/jump skill from the stored base plus the
|
||||
/// CURRENT spellbook state (vitae + skill enchantments) — matching
|
||||
/// retail's <c>CEnchantmentRegistry::EnchantSkill</c> (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 <see cref="Spellbook.EnchantmentsChanged"/>
|
||||
/// notification (a vitae/buff change alone must move the produced rate
|
||||
/// without a fresh PlayerDescription).
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
/// <summary>
|
||||
/// Installs immutable DAT metadata without transferring its ownership to
|
||||
/// Runtime. The content host may install one table after portal.dat opens.
|
||||
|
|
@ -241,6 +316,8 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
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)
|
||||
{
|
||||
|
|
@ -263,11 +340,14 @@ public sealed class RuntimeCharacterState : IDisposable
|
|||
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;
|
||||
|
|
@ -467,28 +547,42 @@ public sealed class RuntimeCharacterOptionsState
|
|||
public readonly record struct RuntimeMovementSkillSnapshot(
|
||||
int RunSkill,
|
||||
int JumpSkill,
|
||||
long Revision)
|
||||
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)
|
||||
{
|
||||
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-authoritative run/jump values retained independently of any
|
||||
/// graphical movement controller. App applies this borrowed state whenever
|
||||
/// its presentation/physics controller exists or is rebuilt.
|
||||
/// Server-authoritative run/jump/burden/stamina 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. RunSkill/JumpSkill arrive here
|
||||
/// ALREADY vitae/enchantment-adjusted by
|
||||
/// <see cref="RuntimeCharacterState.RecomputeMovementSkills"/>.
|
||||
/// </summary>
|
||||
public sealed class RuntimeMovementSkillState
|
||||
{
|
||||
private int _runSkill = -1;
|
||||
private int _jumpSkill = -1;
|
||||
private float _burden;
|
||||
private int _currentStamina = -1;
|
||||
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 bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
|
||||
public long Revision => Interlocked.Read(ref _revision);
|
||||
public RuntimeMovementSkillSnapshot Snapshot =>
|
||||
new(_runSkill, _jumpSkill, Revision);
|
||||
new(_runSkill, _jumpSkill, Revision, _burden, _currentStamina);
|
||||
|
||||
public void Update(int runSkill, int jumpSkill)
|
||||
{
|
||||
|
|
@ -507,10 +601,37 @@ public sealed class RuntimeMovementSkillState
|
|||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a fresh retail <c>InqLoad</c>-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.
|
||||
/// </summary>
|
||||
public void UpdateBurden(float burden)
|
||||
{
|
||||
if (Burden == burden) return;
|
||||
Volatile.Write(ref _burden, burden);
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void UpdateStamina(int currentStamina)
|
||||
{
|
||||
if (CurrentStamina == currentStamina) return;
|
||||
Volatile.Write(ref _currentStamina, currentStamina);
|
||||
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);
|
||||
Interlocked.Increment(ref _revision);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ public static class RuntimeMovementSkillProjection
|
|||
controller.SetCharacterSkills(
|
||||
snapshot.RunSkill,
|
||||
snapshot.JumpSkill);
|
||||
// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME
|
||||
// seam run/jump skill already used — see the pseudocode doc §9.
|
||||
controller.SetCharacterBurden(snapshot.Burden);
|
||||
controller.SetCharacterStamina(snapshot.CurrentStamina);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ using AcDream.Core.Combat;
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
|
|
@ -44,7 +46,14 @@ public sealed record LiveCharacterSessionBindings(
|
|||
Action<int, int>? OnSkillsUpdated,
|
||||
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
|
||||
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
|
||||
Func<double>? ClientTime);
|
||||
Func<double>? ClientTime,
|
||||
// Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden,
|
||||
// stamina, OR (vitae/enchantment-adjusted) skill values change mid-
|
||||
// session — the reactive re-apply-to-the-live-controller seam, mirroring
|
||||
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
|
||||
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
|
||||
// unchanged.
|
||||
Action? OnMovementStatsUpdated = null);
|
||||
|
||||
public sealed record LiveSocialSessionBindings(
|
||||
ChatLog Chat,
|
||||
|
|
@ -153,10 +162,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
social.TurbineChat,
|
||||
onSkillsUpdated: (runSkill, jumpSkill) =>
|
||||
{
|
||||
character.Character.MovementSkills.Update(
|
||||
// Campaign P Slice P1 (2026-07-30): route the PD/skill
|
||||
// base through the vitae/enchantment-adjusted recompute
|
||||
// (CEnchantmentRegistry::EnchantSkill) instead of writing
|
||||
// MovementSkills directly — see the pseudocode doc §9.
|
||||
character.Character.UpdateMovementSkillBase(
|
||||
runSkill,
|
||||
jumpSkill);
|
||||
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
},
|
||||
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
|
||||
onShortcuts: inventory.OnShortcuts,
|
||||
|
|
@ -174,6 +188,52 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
externalContainers: inventory.ExternalContainers,
|
||||
accepting: IsAccepting));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
|
||||
// the SAME event set IndicatorBarController.UpdateBurden already
|
||||
// reacts to (Strength + augmentation property 0xE6 +
|
||||
// EncumbranceVal property 5, falling back to SumCarriedBurden).
|
||||
// See the pseudocode doc §9.
|
||||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectAdded += h,
|
||||
h => inventory.Objects.ObjectAdded -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectUpdated += h,
|
||||
h => inventory.Objects.ObjectUpdated -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
SubscribeToRecompute<ClientObject>(
|
||||
h => inventory.Objects.ObjectRemoved += h,
|
||||
h => inventory.Objects.ObjectRemoved -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
SubscribeToRecompute<ClientObjectMove>(
|
||||
h => inventory.Objects.ObjectMoved += h,
|
||||
h => inventory.Objects.ObjectMoved -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
SubscribeToRecompute<uint>(
|
||||
h => inventory.Objects.ContainerContentsReplaced += h,
|
||||
h => inventory.Objects.ContainerContentsReplaced -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
SubscribeParameterless(
|
||||
h => inventory.Objects.Cleared += h,
|
||||
h => inventory.Objects.Cleared -= h,
|
||||
() => RecomputeBurden(inventory, character));
|
||||
Subscribe<LocalPlayerState.AttributeKind>(
|
||||
h => character.Character.LocalPlayer.AttributeChanged += h,
|
||||
h => character.Character.LocalPlayer.AttributeChanged -= h,
|
||||
kind =>
|
||||
{
|
||||
if (kind == LocalPlayerState.AttributeKind.Strength)
|
||||
RecomputeBurden(inventory, character);
|
||||
});
|
||||
|
||||
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
|
||||
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
|
||||
Subscribe<LocalPlayerState.VitalKind>(
|
||||
h => character.Character.LocalPlayer.Changed += h,
|
||||
h => character.Character.LocalPlayer.Changed -= h,
|
||||
kind => RecomputeStamina(kind, character));
|
||||
|
||||
_subscriptions.Add(new CombatChatTranslator(
|
||||
character.Combat,
|
||||
social.Chat,
|
||||
|
|
@ -259,6 +319,89 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
ConstructionCheckpoint();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
|
||||
/// job is "something relevant changed, recompute" — thin wrapper over
|
||||
/// <see cref="Subscribe{T}"/> that discards the payload.
|
||||
/// </summary>
|
||||
private void SubscribeToRecompute<T>(
|
||||
Action<Action<T>> attach,
|
||||
Action<Action<T>> detach,
|
||||
Action recompute) =>
|
||||
Subscribe(attach, detach, (T _) => recompute());
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
|
||||
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
|
||||
/// no payload).
|
||||
/// </summary>
|
||||
private void SubscribeParameterless(
|
||||
Action<Action> attach,
|
||||
Action<Action> detach,
|
||||
Action sink)
|
||||
{
|
||||
Action handler = () =>
|
||||
{
|
||||
if (Volatile.Read(ref _accepting) != 0)
|
||||
sink();
|
||||
};
|
||||
|
||||
attach(handler);
|
||||
_subscriptions.Add(() => detach(handler));
|
||||
|
||||
ConstructionCheckpoint();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
|
||||
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
|
||||
/// property 5, falling back to the summed carried burden) — the SAME
|
||||
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
|
||||
/// <c>InventoryController.RefreshBurden</c> already use for the burden
|
||||
/// HUD. See the pseudocode doc §2/§9.
|
||||
/// </summary>
|
||||
private static void RecomputeBurden(
|
||||
LiveInventorySessionBindings inventory,
|
||||
LiveCharacterSessionBindings character)
|
||||
{
|
||||
uint player = inventory.PlayerGuid();
|
||||
ClientObject? playerObject = inventory.Objects.Get(player);
|
||||
int strength = (int)(character.Character.LocalPlayer
|
||||
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u);
|
||||
int aug = playerObject?.Properties.GetInt(
|
||||
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
|
||||
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);
|
||||
int burden = playerObject is not null
|
||||
&& playerObject.Properties.Ints.TryGetValue(
|
||||
(uint)PropertyInt.EncumbranceVal, out int wireBurden)
|
||||
? wireBurden
|
||||
: inventory.Objects.SumCarriedBurden(player);
|
||||
float load = EncumbranceSystem.Load(capacity, burden);
|
||||
character.Character.MovementSkills.UpdateBurden(load);
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
|
||||
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
|
||||
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
|
||||
/// effective-skill-zeroing gate (pseudocode doc §5).
|
||||
/// </summary>
|
||||
private static void RecomputeStamina(
|
||||
LocalPlayerState.VitalKind kind,
|
||||
LiveCharacterSessionBindings character)
|
||||
{
|
||||
if (kind != LocalPlayerState.VitalKind.Stamina) return;
|
||||
if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina)
|
||||
is not LocalPlayerState.VitalSnapshot stamina)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
character.Character.MovementSkills.UpdateStamina((int)stamina.Current);
|
||||
character.OnMovementStatsUpdated?.Invoke();
|
||||
}
|
||||
|
||||
private void ConstructionCheckpoint() =>
|
||||
_constructionCheckpoint?.Invoke(++_constructionStep);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue