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>
637 lines
22 KiB
C#
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 < 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);
|
|
}
|
|
}
|