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

@ -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

View file

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

View file

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

View file

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