acdream/src/AcDream.Core/Physics/PlayerWeenie.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

225 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace AcDream.Core.Physics;
/// <summary>
/// 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).
///
/// <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) — polarity PROVEN by raw byte decode of the PDB-paired
/// binary (P1 Opus review, 2026-07-30): <c>fld load; fcomp [0x007c5e24
/// = 2.0f]; fnstsw; test ah,5; jp → return 0</c>, i.e. return 1 iff
/// load &lt; 2.0 (≥ 2.0 or unordered/NaN refuses). Former register row
/// UN-8 retired on this evidence; see the byte decode in
/// <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c> §12.
/// </summary>
public const float CanJumpLoadThreshold = 2.0f;
private int _runSkill;
private int _jumpSkill;
private float _burden;
/// <summary>
/// TS-23 §12b (Campaign P Slice P3, 2026-07-30): raw
/// <c>PropertyInt.PlayerKillerStatus</c> (0x86). <c>null</c> = never
/// pushed (matches every pre-P3 caller — no PK-timer bump, today's
/// behavior unchanged).
/// </summary>
private int? _playerKillerStatus;
/// <summary>
/// TS-23 §12b: raw <c>PropertyFloat.LastPkAttackTimestamp</c> (0x91).
/// <c>null</c> = never pushed / property absent.
/// </summary>
private float? _lastPkAttackTimestamp;
/// <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;
_jumpSkill = jumpSkill;
_burden = burden;
}
public void SetSkills(int runSkill, int jumpSkill)
{
_runSkill = runSkill;
_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;
/// <summary>
/// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw
/// <c>PropertyInt.PlayerKillerStatus</c> (0x86) / <c>PropertyFloat.
/// LastPkAttackTimestamp</c> (0x91) pair <c>CACQualities::
/// JumpStaminaCost</c> (0x00591b90) reads for its 20-second PK-timer
/// jump-stamina-cost bump. Both <c>null</c> restores "never pushed" (no
/// bump, matching every pre-P3 caller). The 20-second recency window
/// itself is evaluated fresh at <see cref="JumpStaminaCost"/> call time
/// against a live clock, not cached here — the window's EXPIRY has no
/// wire event to re-push on.
/// </summary>
public void SetPlayerKillerStatus(int? playerKillerStatus, float? lastPkAttackTimestamp)
{
_playerKillerStatus = playerKillerStatus;
_lastPkAttackTimestamp = lastPkAttackTimestamp;
}
public bool InqRunRate(out float rate)
{
int effectiveSkill = _currentStamina == 0 ? 0 : _runSkill;
rate = MovementSystem.GetRunRate(_burden, effectiveSkill);
return true;
}
public bool InqJumpVelocity(float extent, out float vz)
{
int effectiveSkill = _currentStamina == 0 ? 0 : _jumpSkill;
float height = MovementSystem.GetJumpHeight(_burden, effectiveSkill, extent);
vz = MathF.Sqrt(height * 19.6f);
return true;
}
/// <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.
/// </summary>
public bool IsThePlayer() => true;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// TS-23 §12b (2026-07-30): <c>pk</c> is retail's own formula —
/// <c>PlayerKillerStatus</c> (0x86) in {4 (PK), 0x40 (PKLite)} AND
/// <c>(LastPkAttackTimestamp + 20.0) &gt;= Timer::cur_time</c>. Both
/// <see cref="_playerKillerStatus"/> defaulting to <c>8</c> (retail's own
/// <c>InqInt</c> default) and an absent timestamp evaluate to
/// <c>pk = false</c> — bit-identical to the pre-P3 hardcoded value for
/// every non-PK/PKLite character, which is every ACE default-created
/// character (the invariant this port must not break).
/// <para>
/// "Now" uses <see cref="Environment.TickCount64"/> (process-uptime
/// milliseconds), NOT an absolute wall-clock epoch. A first attempt
/// used <see cref="DateTimeOffset.UtcNow"/>'s Unix-epoch seconds and a
/// conformance test caught the bug it produces: <c>LastPkAttackTimestamp</c>
/// is a wire <c>PropertyFloat</c> (32-bit, ~7 significant decimal
/// digits) — at a ~1.7-billion-second Unix epoch magnitude, float
/// precision only resolves to roughly ±128 seconds, so a 20-second
/// recency window is entirely swallowed by rounding error (a `now 30s`
/// timestamp computed the SAME cost as `now`). Retail's own
/// <c>Timer::cur_time</c> almost certainly is NOT an absolute epoch for
/// exactly this reason — a small process/session-relative counter is
/// the only magnitude a 32-bit float can hold with sub-second precision
/// for a meaningful session length. This is a documented, evidence-based
/// clock CHOICE (a precision bug the test caught, not a guess dressed up
/// as fact) — the exact retail epoch/basis was not independently
/// confirmed (ACE does not model either property server-side, so this
/// branch is inert against every local-ACE test scenario regardless of
/// the clock's exact basis; flagged for cdb confirmation if a real PK
/// server is ever tested against).
/// </para>
/// </remarks>
public bool JumpStaminaCost(float extent, out int cost)
{
bool pk = false;
int pkStatus = _playerKillerStatus ?? 8; // retail InqInt default when the property is absent
if ((pkStatus == 4 || pkStatus == 0x40) && _lastPkAttackTimestamp is { } ts)
{
float now = Environment.TickCount64 / 1000f;
pk = (ts + 20.0f) >= now;
}
cost = MovementSystem.JumpStaminaCost(extent, _burden, pk);
return true;
}
/// <summary>
/// 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) =>
MovementSystem.GetRunRate(burden, runSkill);
/// <summary>
/// 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) =>
MovementSystem.GetJumpHeight(burden, jumpSkill, extent);
/// <summary>
/// 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) => EncumbranceSystem.LoadMod(burden);
}