acdream/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs
Erik bb7b899bfe fix(physics): TS-23 - plumb real PK/PKLite/Impenetrable mover flags
Campaign P Slice P3 item 3. The wire parse (CreateObject's
PublicWeenieDesc._bitfield), the decode (EntityCollisionFlagsExt.
FromPwdBitfield), the per-GUID storage (ClientObjectTable.
PublicWeenieBitfield), and the exemption logic (CollisionExemption.
ShouldSkip) all already existed and were already correct -- every
mover-flags call site just fed a GUID-prefix IsPlayer heuristic instead
of the real per-entity PK/PKLite/Impenetrable state (retail
OBJECTINFO::init 0x0050cf30 state |= 0x80/0x800/0x1000).

Port:
- EntityCollisionFlagsExt.ToMoverState translates the decoded PWD
  bit-space into the ObjectInfoState bit-space FindObjCollisions
  actually reads -- two different numberings that must not be
  confused. Deliberately does not translate IsPlayer (every call site
  already derives that correctly from its own GUID heuristic per
  #184 Slice 2b).
- EntityCollisionFlagsExt.ResolveMoverPvpState is the one shared
  ClientObjectTable-backed lookup (guid -> ObjectInfoState), replacing
  what would otherwise have been three separate inline copies across
  GameWindow/LivePresentationComposition/RemoteTeleportController.
- Threaded as a new optional moverPvpState parameter through
  RuntimeRemotePhysicsUpdater.Tick/TickHidden and
  RuntimeOrdinaryPhysicsUpdater.TryBegin (default None preserves every
  pre-P3 caller unchanged), and as PlayerMovementController.OwnPvpFlags
  for the local player's own two resolve call sites.
- TS-23 section 12b: PlayerWeenie.JumpStaminaCost's pk parameter now
  reads the real PlayerKillerStatus(0x86)/LastPkAttackTimestamp(0x91)
  pair against retail's 20-second recency window
  (pkStatus in {4, 0x40} && (timestamp + 20.0) >= now), replacing the
  P1 hardcoded false. RuntimeMovementSkillState/Snapshot and
  LiveSessionEventRouter.RecomputePvpStatus push both the PWD bitfield
  and the PlayerKillerStatus pair reactively, riding the SAME
  ClientObject event triggers RecomputeBurden already uses.
- A conformance test caught a genuine precision bug in the first
  PK-timer clock choice: DateTimeOffset.UtcNow's Unix-epoch seconds
  (~1.7 billion) loses ~128 seconds of precision in a 32-bit float,
  silently swallowing the entire 20-second window. Switched to
  Environment.TickCount64 (small, monotonic magnitude) -- also the more
  retail-plausible basis, since LastPkAttackTimestamp is itself a wire
  PropertyFloat and retail's Timer::cur_time is almost certainly a
  process/session-relative counter for the same precision reason, not
  an absolute epoch.

Non-PK invariant (the acceptance criterion): an entity with no
ClientObjectTable row, or a row whose PublicWeenieBitfield is null or
0, resolves to ObjectInfoState.None -- a no-op OR into moverFlags,
bit-identical to every pre-P3 caller's hardcoded value. A dedicated
test drives two real ClientObjectTable rows through
CollisionExemption.ShouldSkip and confirms PK-vs-PK collides while
PK-vs-non-PK and non-PK-vs-non-PK both stay exempt (walk through).

Register: TS-23 retired (both the collision-flags and PK-timer halves);
the stale "M2 combat must land TS-23" phase-gate note removed.

dotnet build + dotnet test (Core.Tests 4008/2 skip, Runtime.Tests
425/0, App.Tests 3968/3 skip, complete solution build) all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:52:55 +02:00

721 lines
26 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
&& MovementSkills.OwnPwdBitfield == 0u
&& MovementSkills.PlayerKillerStatus == -1
&& MovementSkills.LastPkAttackTimestamp is null
&& _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,
// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
// PublicWeenieDesc._bitfield (0 = never pushed / no PK-relevant bits —
// a no-op OR, matching every pre-P3 caller) and the raw
// PlayerKillerStatus/LastPkAttackTimestamp pair §12b's PK-timer
// jump-cost bump reads (-1 / null = never pushed).
uint OwnPwdBitfield = 0u,
int PlayerKillerStatus = -1,
float? LastPkAttackTimestamp = null)
{
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
}
/// <summary>
/// Server-authoritative run/jump/burden/stamina/PK-status 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. Campaign P Slice P3 (2026-07-30)
/// further added the player's own PWD bitfield (PK/PKLite/Impenetrable
/// collision-exemption bits) and the PlayerKillerStatus/
/// LastPkAttackTimestamp pair (the PK-timer jump-cost bump) — the SAME
/// reactive push seam, since both ride the player's own ClientObject
/// property-update stream. 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 uint _ownPwdBitfield;
private int _playerKillerStatus = -1;
private float _lastPkAttackTimestamp;
private bool _hasLastPkAttackTimestamp;
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 uint OwnPwdBitfield => Volatile.Read(ref _ownPwdBitfield);
public int PlayerKillerStatus => Volatile.Read(ref _playerKillerStatus);
public float? LastPkAttackTimestamp =>
Volatile.Read(ref _hasLastPkAttackTimestamp)
? Volatile.Read(ref _lastPkAttackTimestamp)
: null;
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
public long Revision => Interlocked.Read(ref _revision);
public RuntimeMovementSkillSnapshot Snapshot =>
new(
_runSkill,
_jumpSkill,
Revision,
_burden,
_currentStamina,
OwnPwdBitfield,
PlayerKillerStatus,
LastPkAttackTimestamp);
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);
}
/// <summary>
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's own
/// raw <c>PublicWeenieDesc._bitfield</c> — the PWD wire bit-space
/// <see cref="AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield"/>
/// decodes. Fires on the SAME <c>ClientObject</c> add/update events
/// <see cref="UpdateBurden"/> already reacts to (the player's own
/// PublicWeenieBitfield lives on the same row).
/// </summary>
public void UpdateOwnPwdBitfield(uint bitfield)
{
if (OwnPwdBitfield == bitfield) return;
Volatile.Write(ref _ownPwdBitfield, bitfield);
Interlocked.Increment(ref _revision);
}
/// <summary>
/// TS-23 §12b: pushes the raw <c>PlayerKillerStatus</c>
/// (<c>PropertyInt</c> 0x86) / <c>LastPkAttackTimestamp</c>
/// (<c>PropertyFloat</c> 0x91) pair feeding
/// <c>CACQualities::JumpStaminaCost</c>'s 20-second PK-timer bump. A
/// negative <paramref name="playerKillerStatus"/> restores "never
/// pushed" (matches <see cref="UpdateStamina"/>'s own sentinel
/// convention); <paramref name="lastPkAttackTimestamp"/> is <c>null</c>
/// when the property is absent.
/// </summary>
public void UpdatePlayerKillerStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
{
bool changed = false;
if (PlayerKillerStatus != playerKillerStatus)
{
Volatile.Write(ref _playerKillerStatus, playerKillerStatus);
changed = true;
}
bool hasTimestamp = lastPkAttackTimestamp.HasValue;
float timestamp = lastPkAttackTimestamp ?? 0f;
if (Volatile.Read(ref _hasLastPkAttackTimestamp) != hasTimestamp
|| (hasTimestamp && Volatile.Read(ref _lastPkAttackTimestamp) != timestamp))
{
Volatile.Write(ref _lastPkAttackTimestamp, timestamp);
Volatile.Write(ref _hasLastPkAttackTimestamp, hasTimestamp);
changed = true;
}
if (changed)
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);
Volatile.Write(ref _ownPwdBitfield, 0u);
Volatile.Write(ref _playerKillerStatus, -1);
Volatile.Write(ref _lastPkAttackTimestamp, 0f);
Volatile.Write(ref _hasLastPkAttackTimestamp, false);
Interlocked.Increment(ref _revision);
}
}