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

113 lines
5.1 KiB
C#

using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>MovementSystem</c> (named-retail decomp pc 695958+): the pure
/// formula layer <c>PlayerWeenie</c> (the CACQualities-shaped composition)
/// calls once burden, skill, and stamina inputs are assembled. See
/// <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c> §6.
///
/// <para>
/// <b>x87-mush disclosure:</b> <see cref="GetRunRate"/>'s general-case
/// arithmetic and <see cref="JumpStaminaCost"/>'s <c>pk!=0</c> branch are
/// entirely dropped by the BN decompiler (not partially garbled — the
/// operand expressions never survive translation, unlike the polarity-only
/// ambiguities elsewhere in this port). Both are cross-referenced against
/// <c>references/ACE/Source/ACE.Server/Physics/Animation/MovementSystem.cs</c>,
/// which already matched this exact acdream port's PRE-P1 code
/// (<c>PlayerWeenie.GetRunRate</c>/<c>GetJumpHeight</c> already cited "decompiled
/// + ACE MovementSystem" before this slice) — no behavior change for the
/// formulas already live; only the retail-named surface and the 3rd/4th
/// <c>scaling</c> parameters (every known call site passes <c>1f</c>) are new.
/// </para>
/// </summary>
public static class MovementSystem
{
/// <summary>
/// <c>MovementSystem::GetRunRate</c> 0x006b0950. The 800 branch is an
/// EXACT-EQUALITY sentinel, not a cap: raw byte decode of the PDB-paired
/// binary (#266, 2026-07-30) shows <c>fild skill; fcom [800f]; fnstsw;
/// test ah, 0x44; jp general</c> — the C2/C3 parity idiom that falls
/// through to <c>18/4</c> ONLY when skill == 800 (&lt;, &gt;, and
/// unordered all take the general path). <c>InqMaxRunRate</c> (0x00591b20)
/// passes skill=9999 and therefore gets the general formula's ~3.6961,
/// NOT 4.5. The general-path arithmetic is byte-verified
/// instruction-by-instruction: <c>(loadMod * (skill/(skill+200) * 11) +
/// 4) / scaling / 4</c>.
/// </summary>
/// <remarks>
/// ACE's <c>MovementSystem.GetRunRate</c> reads this branch as
/// <c>&gt;= 800</c> ("max run speed?") — that is ACE's misread of the
/// same x87 mush, NOT a tiebreaker. Porting <c>&gt;=</c> made every
/// maxed character run a flat 4.5 (vs retail's ~3.70) and erased the
/// vitae speed differential entirely (#266). Do not "fix" this back
/// from ACE.
/// </remarks>
public static float GetRunRate(float burden, int runSkill, float scaling = 1f)
{
if (runSkill == 800)
return 18f / 4f;
float loadMod = EncumbranceSystem.LoadMod(burden);
return ((loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / scaling) / 4f;
}
/// <summary>
/// <c>MovementSystem::GetJumpHeight</c> 0x006b09b0. Fully readable except
/// the extent-clamp micro-branch (x87 mush, ACE's <c>Math.Clamp(power,0,1)</c>
/// is the tiebreaker — matches the pre-P1 acdream port unchanged).
/// </summary>
public static float GetJumpHeight(
float burden,
int jumpSkill,
float power,
float scaling = 1f)
{
power = Math.Clamp(power, 0f, 1f);
float loadMod = EncumbranceSystem.LoadMod(burden);
float result = loadMod
* ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f)
* power
/ scaling;
return result < 0.35f ? 0.35f : result;
}
/// <summary>
/// <c>MovementSystem::JumpStaminaCost</c> 0x006b0a40. The <c>pk==0</c>
/// branch is fully readable: <c>ceil((load + 0.5) * power * 8 + 2)</c> —
/// note <c>load</c> (not <c>power</c>) carries the <c>+0.5</c>; the
/// campaign plan's shorthand had the operands swapped (see pseudocode
/// doc §6). The <c>pk!=0</c> branch is entirely dropped by BN; ACE's
/// <c>(power+1.0)*100.0</c> is the tiebreaker. <paramref name="pk"/> is
/// the real <c>PlayerKillerStatus</c>/<c>LastPkAttackTimestamp</c>
/// 20-second-window predicate as of TS-23 (Campaign P Slice P3,
/// 2026-07-30) — see <see cref="PlayerWeenie.JumpStaminaCost"/>.
/// </summary>
public static int JumpStaminaCost(float power, float burden, bool pk)
{
if (pk)
return (int)((power + 1.0f) * 100.0f);
return (int)Math.Ceiling((burden + 0.5f) * power * 8f + 2f);
}
/// <summary>
/// <c>MovementSystem::GetJumpPower</c> — the algebraic inverse of
/// <see cref="JumpStaminaCost"/>, solving for the extent affordable at a
/// given stamina. Present in ACE (uncommented, live utility) but its
/// retail call site is the charge-power-meter UI outside
/// <c>CMotionInterp</c> (0x0056afac, out of R3/P1 scope — see
/// <c>ChargeJump</c>'s doc comment in <c>MotionInterpreter.cs</c>). Not
/// consumed by P1; ported for signature completeness and to leave the
/// formula available for the charge-meter follow-up without a second
/// decomp pass.
/// </summary>
public static float GetJumpPower(uint stamina, float burden, bool pk)
{
if (pk)
return stamina / 100.0f - 1.0f;
return (stamina - 2.0f) / (burden * 8.0f + 4.0f);
}
}