namespace AcDream.Core.Physics; /// /// IWeenieObject implementation for the local player — the C# analogue of /// retail's CACQualities "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 IsThePlayer() — pc 406512+, confirming this /// query family only ever reaches the LOCAL player's weenie). /// /// /// Campaign P Slice P1 (2026-07-30): burden, current stamina, and the /// vitae/enchantment-adjusted run/jump skill are now real, pushed inputs — /// see docs/research/2026-07-30-stat-coupled-movement-pseudocode.md. /// _runSkill/_jumpSkill arrive ALREADY EnchantSkill-adjusted /// (vitae + skill enchantments folded in by /// AcDream.Runtime.Gameplay.RuntimeCharacterState.RecomputeMovementSkills) /// — this class stays a pure formula consumer with no Spellbook/enchantment /// dependency, matching retail's OWN split between CACQualities (the /// query surface) and CEnchantmentRegistry (the buff aggregator it /// calls into internally). /// /// /// /// Formulas from decompiled acclient.exe ( / /// ), cross-referenced against ACE /// MovementSystem.GetRunRate/GetJumpHeight. /// /// public sealed class PlayerWeenie : IWeenieObject { /// /// Retail CACQualities::CanJump's hard burden gate (0x00591b50, /// pc 412907) — polarity PROVEN by raw byte decode of the PDB-paired /// binary (P1 Opus review, 2026-07-30): fld load; fcomp [0x007c5e24 /// = 2.0f]; fnstsw; test ah,5; jp → return 0, 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 /// docs/research/2026-07-30-stat-coupled-movement-pseudocode.md §12. /// public const float CanJumpLoadThreshold = 2.0f; private int _runSkill; private int _jumpSkill; private float _burden; /// /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): raw /// PropertyInt.PlayerKillerStatus (0x86). null = never /// pushed (matches every pre-P3 caller — no PK-timer bump, today's /// behavior unchanged). /// private int? _playerKillerStatus; /// /// TS-23 §12b: raw PropertyFloat.LastPkAttackTimestamp (0x91). /// null = never pushed / property absent. /// private float? _lastPkAttackTimestamp; /// /// Retail AttributeCache::InqAttribute2nd(ATTR2ND_STAMINA=4)'s /// current-stamina reading, consulted by InqRunRate/ /// InqJumpVelocity to zero the effective skill when exhausted /// (pc 413824-413898, 413902-413979). null = never pushed /// (matches every pre-P1 caller/test — no gating, today's behavior /// unchanged); any non-negative value including 0 is a real reading. /// 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; } /// /// Pushes the retail InqLoad-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 IndicatorBarController.UpdateBurden already /// assembles) and pushes it — see the pseudocode doc §9. /// public void SetBurden(float burden) => _burden = burden; /// /// Pushes the current-stamina reading feeding the zero-skill gate in /// /. Pass /// null to return to "unknown, don't gate" (matches construction /// default). /// public void SetStamina(uint? currentStamina) => _currentStamina = currentStamina; /// /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw /// PropertyInt.PlayerKillerStatus (0x86) / PropertyFloat. /// LastPkAttackTimestamp (0x91) pair CACQualities:: /// JumpStaminaCost (0x00591b90) reads for its 20-second PK-timer /// jump-stamina-cost bump. Both null restores "never pushed" (no /// bump, matching every pre-P3 caller). The 20-second recency window /// itself is evaluated fresh at call time /// against a live clock, not cached here — the window's EXPIRY has no /// wire event to re-push on. /// 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; } /// /// Retail CACQualities::CanJump (0x00591b50): refuses only past /// (200% load) — see the class doc's /// UN-8 note. TS-5 retired: this was previously an unconditional /// true. /// public bool CanJump(float extent) => _burden < CanJumpLoadThreshold; /// /// R3-W3 (W0-pins.md A3): the local player's weenie is THE player. /// Feeds W4's apply_current_movement/ReportExhaustion /// dual-dispatch gate. /// public bool IsThePlayer() => true; /// /// Retail CACQualities::JumpStaminaCost (0x00591b90, pc 412949, /// FULLY READABLE): computes the real cost via /// and returns true /// 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. /// /// /// TS-23 §12b (2026-07-30): pk is retail's own formula — /// PlayerKillerStatus (0x86) in {4 (PK), 0x40 (PKLite)} AND /// (LastPkAttackTimestamp + 20.0) >= Timer::cur_time. Both /// defaulting to 8 (retail's own /// InqInt default) and an absent timestamp evaluate to /// pk = false — 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). /// /// "Now" uses (process-uptime /// milliseconds), NOT an absolute wall-clock epoch. A first attempt /// used 's Unix-epoch seconds and a /// conformance test caught the bug it produces: LastPkAttackTimestamp /// is a wire PropertyFloat (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 /// Timer::cur_time 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). /// /// 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; } /// /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4. /// Returns 4.5 only for the retail skill == 800 sentinel (byte-decoded /// exact-equality branch, #266 — NOT a cap). Thin forwarder to /// — kept for source /// compatibility with existing golden-value tests. /// public static float GetRunRate(float burden, int runSkill) => MovementSystem.GetRunRate(burden, runSkill); /// /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent, /// clamped to minimum 0.35m. Thin forwarder to /// — kept for source /// compatibility with existing golden-value tests. /// public static float GetJumpHeight(float burden, int jumpSkill, float extent) => MovementSystem.GetJumpHeight(burden, jumpSkill, extent); /// /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at /// 200%. Thin forwarder to — /// kept for source compatibility with existing golden-value tests. /// public static float GetBurdenMod(float burden) => EncumbranceSystem.LoadMod(burden); }