acdream/src/AcDream.Core/Physics/PlayerWeenie.cs
Erik 61e959169b fix #266: retail run-rate 800 branch is exact-equality sentinel, not a cap
Raw byte decode of MovementSystem::GetRunRate (0x006b0950, PDB-paired
binary): fild skill; fcom [800f]; fnstsw; test ah, 0x44; jp general —
the C2/C3 parity idiom whose 18/4 fall-through executes ONLY at
skill == 800 exactly. ACE read this as >= 800 ('max run speed?') and
Campaign P P1 inherited that misread when BN dropped the arithmetic,
flat-lining every maxed character at 4.5 (retail-true ~3.70, +21%) and
erasing the vitae differential (both 10200 and 15225 sat above 800).

The [stat-chain] live capture proved the enchant chain correct end to
end (vitae 0.67 -> eff run 10200 -> controller), isolating the formula.
General path byte-verified: (loadMod*(skill/(skill+200)*11)+4)/scaling/4.
InqMaxRunRate's skill=9999 probe gets ~3.6961, not 4.5.

Golden tests pin the 799/800/801 straddle and the maxed-skill vitae
differential; pseudocode doc §6 carries the decode plus a do-not-
reimport-ACE warning. Complete Release suite: 10,025 passed / 5 skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:22:41 +02:00

226 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.
/// Returns 4.5 only for the retail skill == 800 sentinel (byte-decoded
/// exact-equality branch, #266 — NOT a cap). 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);
}