acdream/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
Erik 9355ddcec6 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>
2026-07-30 08:18:06 +02:00

637 lines
22 KiB
C#

using AcDream.Core.Player;
using AcDream.Core.Net.Messages;
using AcDream.Core.Spells;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
public readonly record struct RuntimeCharacterOwnershipSnapshot(
bool IsDisposed,
bool InternalSubscriptionsAttached,
int LearnedSpellCount,
int ActiveEnchantmentCount,
int DesiredComponentCount,
int FavoriteSpellCount,
int VitalCount,
int AttributeCount,
int SkillCount,
int PositionCount,
int PropertyCount,
bool OptionsAreDefaults,
bool MovementSkillsAreReset)
{
public bool IsConverged =>
IsDisposed
&& !InternalSubscriptionsAttached
&& LearnedSpellCount == 0
&& ActiveEnchantmentCount == 0
&& DesiredComponentCount == 0
&& FavoriteSpellCount == 0
&& VitalCount == 0
&& AttributeCount == 0
&& SkillCount == 0
&& PositionCount == 0
&& PropertyCount == 0
&& OptionsAreDefaults
&& MovementSkillsAreReset;
}
/// <summary>
/// Canonical presentation-independent owner for the local character's magic
/// and player-sheet state. The two objects form one lifetime group because
/// vital maxima read active enchantments from this exact spellbook.
/// </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);
LocalPlayer = new LocalPlayerState(Spellbook);
Options = new RuntimeCharacterOptionsState();
MovementSkills = new RuntimeMovementSkillState();
View = new CharacterView(this);
Spellbook.StateChanged += OnSpellbookChanged;
Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement;
LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged;
_internalSubscriptionsAttached = true;
}
public Spellbook Spellbook { get; }
public LocalPlayerState LocalPlayer { get; }
public RuntimeCharacterOptionsState Options { get; }
public RuntimeMovementSkillState MovementSkills { get; }
public IRuntimeCharacterView View { get; }
public bool IsDisposed => _disposed;
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
{
int favoriteCount = 0;
for (int tab = 0; tab < 8; tab++)
favoriteCount += Spellbook.GetFavorites(tab).Count;
int vitalCount = 0;
foreach (LocalPlayerState.VitalKind kind
in Enum.GetValues<LocalPlayerState.VitalKind>())
{
if (LocalPlayer.Get(kind) is not null)
vitalCount++;
}
int attributeCount = 0;
foreach (LocalPlayerState.AttributeKind kind
in Enum.GetValues<LocalPlayerState.AttributeKind>())
{
if (LocalPlayer.GetAttribute(kind) is not null)
attributeCount++;
}
PropertyBundle properties = LocalPlayer.Properties;
int propertyCount =
properties.Bools.Count
+ properties.Ints.Count
+ properties.Int64s.Count
+ properties.Floats.Count
+ properties.Strings.Count
+ properties.DataIds.Count
+ properties.InstanceIds.Count;
RuntimeCharacterOptionsSnapshot options = Options.Snapshot;
return new RuntimeCharacterOwnershipSnapshot(
_disposed,
_internalSubscriptionsAttached,
Spellbook.LearnedCount,
Spellbook.ActiveCount,
Spellbook.DesiredComponents.Count,
favoriteCount,
vitalCount,
attributeCount,
LocalPlayer.Skills.Count,
LocalPlayer.Positions.Count,
propertyCount,
options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1
&& options.Options2
== RuntimeCharacterOptionsState.DefaultOptions2,
MovementSkills.RunSkill == -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.
/// </summary>
public void InstallSpellMetadata(SpellTable spellTable)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.InstallMetadata(spellTable);
}
public void ResetSpellbook()
{
ObjectDisposedException.ThrowIf(_disposed, this);
Spellbook.Clear();
}
public void ResetLocalPlayer()
{
ObjectDisposedException.ThrowIf(_disposed, this);
LocalPlayer.Clear();
}
/// <summary>
/// Apply retail's local favorite insertion before sending the matching
/// character event.
/// </summary>
public bool TryAddFavorite(
int tabIndex,
int position,
uint spellId,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u)
return false;
Spellbook.SetFavorite(tabIndex, position, spellId);
publishOutbound();
return true;
}
public bool TryRemoveFavorite(
int tabIndex,
uint spellId,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if ((uint)tabIndex >= 8u || spellId == 0u)
return false;
Spellbook.RemoveFavorite(tabIndex, spellId);
publishOutbound();
return true;
}
/// <summary>
/// Apply and publish a spellbook filter only when it differs, matching
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
/// </summary>
public void SetSpellbookFilter(
uint filters,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if (Spellbook.SpellbookFilters == filters)
return;
Spellbook.SetSpellbookFilters(filters);
publishOutbound();
}
/// <summary>
/// Retail publishes the desired-component event before changing its local
/// PlayerModule table.
/// </summary>
public bool TrySetDesiredComponent(
uint componentId,
uint amount,
Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
if (componentId == 0u || amount > 5000u)
return false;
try
{
publishOutbound();
}
finally
{
Spellbook.SetDesiredComponent(componentId, amount);
}
return true;
}
public void ClearDesiredComponents(Action publishOutbound)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(publishOutbound);
try
{
publishOutbound();
}
finally
{
Spellbook.ClearDesiredComponents();
}
}
/// <summary>
/// Clears both coupled owners while retaining every failed suffix for a
/// retry. State mutation happens before the existing synchronous
/// invalidation callbacks, so retrying is safe and convergent.
/// </summary>
public void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
List<Exception>? failures = null;
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)
{
throw new AggregateException(
"Runtime character state did not converge during reset.",
failures);
}
}
public void Dispose()
{
if (_disposed)
return;
List<Exception>? failures = null;
try
{
// Do not call ResetSession as one opaque step here. Terminal
// disposal must run every suffix even when an external UI observer
// throws from one Core owner's synchronous clear notification.
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;
_internalSubscriptionsAttached = false;
_disposed = true;
}
if (failures is not null)
{
throw new AggregateException(
"Runtime character state did not converge during disposal.",
failures);
}
}
private void OnSpellbookChanged() =>
Interlocked.Increment(ref _spellbookRevision);
private void OnVitalChanged(LocalPlayerState.VitalKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
Interlocked.Increment(ref _characterRevision);
private void OnCharacterChanged() =>
Interlocked.Increment(ref _characterRevision);
private static void Try(Action action, ref List<Exception>? failures)
{
try
{
action();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
private sealed class CharacterView(RuntimeCharacterState owner)
: IRuntimeCharacterView
{
public RuntimeCharacterSnapshot Snapshot => new(
Interlocked.Read(ref owner._characterRevision),
Interlocked.Read(ref owner._spellbookRevision),
owner.Options.Snapshot,
owner.MovementSkills.Snapshot,
owner.Spellbook.LearnedSpells.Count,
owner.Spellbook.ActiveEnchantments.Count(),
owner.Spellbook.DesiredComponents.Count,
owner.LocalPlayer.Skills.Count,
owner.Spellbook.SpellbookFilters);
public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital)
{
if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind)
|| owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind)
is not LocalPlayerState.VitalSnapshot current)
{
vital = default;
return false;
}
vital = new RuntimeVitalSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current,
owner.LocalPlayer.GetMaxApprox(
(LocalPlayerState.VitalKind)kind) ?? 0u);
return true;
}
public bool TryGetAttribute(
int kind,
out RuntimeAttributeSnapshot attribute)
{
if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind)
|| owner.LocalPlayer.GetAttribute(
(LocalPlayerState.AttributeKind)kind)
is not LocalPlayerState.AttributeSnapshot current)
{
attribute = default;
return false;
}
attribute = new RuntimeAttributeSnapshot(
kind,
current.Ranks,
current.Start,
current.Xp,
current.Current);
return true;
}
public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill)
{
if (owner.LocalPlayer.GetSkill(skillId)
is not LocalPlayerState.SkillSnapshot current)
{
skill = default;
return false;
}
skill = new RuntimeSkillSnapshot(
current.SkillId,
current.Ranks,
current.Status,
current.Xp,
current.Init,
current.Resistance,
current.LastUsed,
current.FormulaBonus,
current.CurrentLevel);
return true;
}
public bool KnowsSpell(uint spellId) =>
owner.Spellbook.Knows(spellId);
public bool TryGetFavorite(
int tabIndex,
int position,
out uint spellId)
{
IReadOnlyList<uint> favorites =
owner.Spellbook.GetFavorites(tabIndex);
if ((uint)position >= (uint)favorites.Count)
{
spellId = 0u;
return false;
}
spellId = favorites[position];
return true;
}
public bool TryGetDesiredComponent(
uint componentId,
out uint amount) =>
owner.Spellbook.DesiredComponents.TryGetValue(
componentId,
out amount);
}
}
public readonly record struct RuntimeCharacterOptionsSnapshot(
uint Options1,
uint Options2,
long Revision)
{
public bool DragItemOnPlayerOpensSecureTrade =>
(Options1
& (uint)PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0u;
}
/// <summary>
/// Canonical session-owned copy of retail's two character-option bitfields.
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
/// Runtime reset restores the equivalent fresh-player-module state because
/// one Runtime owner survives across graphical and no-window sessions.
/// </summary>
public sealed class RuntimeCharacterOptionsState
{
public const uint DefaultOptions1 =
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
public const uint DefaultOptions2 = 0x00948700u;
private uint _options1 = DefaultOptions1;
private uint _options2 = DefaultOptions2;
private long _revision;
public uint Options1 => Volatile.Read(ref _options1);
public uint Options2 => Volatile.Read(ref _options2);
public long Revision => Interlocked.Read(ref _revision);
public RuntimeCharacterOptionsSnapshot Snapshot =>
new(_options1, _options2, Revision);
public bool DragItemOnPlayerOpensSecureTrade =>
Snapshot.DragItemOnPlayerOpensSecureTrade;
public void Replace(uint options1, uint options2)
{
Volatile.Write(ref _options1, options1);
Volatile.Write(ref _options2, options2);
Interlocked.Increment(ref _revision);
}
public void ResetSession()
{
Volatile.Write(ref _options1, DefaultOptions1);
Volatile.Write(ref _options2, DefaultOptions2);
Interlocked.Increment(ref _revision);
}
}
public readonly record struct RuntimeMovementSkillSnapshot(
int RunSkill,
int JumpSkill,
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/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, _burden, _currentStamina);
public void Update(int runSkill, int jumpSkill)
{
bool changed = false;
if (runSkill >= 0 && RunSkill != runSkill)
{
Volatile.Write(ref _runSkill, runSkill);
changed = true;
}
if (jumpSkill >= 0 && JumpSkill != jumpSkill)
{
Volatile.Write(ref _jumpSkill, jumpSkill);
changed = true;
}
if (changed)
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);
}
}