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>
225 lines
10 KiB
C#
225 lines
10 KiB
C#
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 < 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) >= 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);
|
||
}
|