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>
226 lines
10 KiB
C#
226 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.
|
||
/// 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);
|
||
}
|