acdream/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md

24 KiB
Raw Blame History

Campaign P — P1 stat-coupled movement: pseudocode + retail chain

Filed 2026-07-30 ahead of the P1 implementation (burden/stamina/vitae feeding run rate, jump height, jump permission, jump stamina cost). All addresses are from docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR build) unless marked ACE-cross-reference. Ghidra MCP was unavailable for this slice (operator note); ACE (references/ACE/Source/ACE.Server/Physics/) is the tiebreaker wherever BN's x87 mush drops a branch, called out explicitly below.

1. The call chain (top to bottom)

CMotionInterp (our MotionInterpreter.cs, unchanged this slice)
  jump_is_allowed / ChargeJump / JumpChargeIsAllowed
      -> WeenieObj.CanJump(extent)                    [IWeenieObject +0x3C]
      -> WeenieObj.JumpStaminaCost(extent, out cost)   [IWeenieObject +0x44]
  GetJumpVZ -> WeenieObj.InqJumpVelocity(extent, out vz)  [+0x30]
  apply_run_to_command -> WeenieObj.InqRunRate(out rate)  [+0x34]

ACCWeenieObject (thin delegation, pc 406512+)
  CanJump/JumpStaminaCost/InqRunRate/InqJumpVelocity/InqMaxRunRate all
  gate on IsThePlayer() first (0058c400/40/520/560/5a0) — NPCs/monsters/
  remote players never reach m_pQualities for these queries. Confirms P1
  is scoped correctly to PlayerWeenie only; RemoteWeenie is untouched.

CACQualities (the "qualities DB" == our PlayerWeenie, pc 412901-414050)
  InqLoad            0x0058f130  (pc 409756)  — burden/load ratio
  CanJump            0x00591b50               — burden hard-gate
  JumpStaminaCost    0x00591b90               — stamina cost + PK flag
  InqRunRate         0x00592800               — full skill+vitae chain
  InqJumpVelocity    0x00592980               — mirrors InqRunRate for Jump

MovementSystem (pure formulas, pc 695958+)
  GetRunRate         0x006b0950
  GetJumpHeight       0x006b09b0
  JumpStaminaCost     0x006b0a40

EncumbranceSystem (pure formulas, pc 256393+)
  EncumbranceCapacity 0x004fcc00
  Load                0x004fcc40
  LoadMod             0x004fcc70

2. InqLoad (0x0058f130, pc 409756) — FULLY READABLE

InqLoad(this, &loadOut):
    strength = InqAttribute(this, ATTRIBUTE_STRENGTH=1)          // default 0xa if absent
    aug      = InqInt(this, PROPERTY_INT_AUGMENTATION_INCREASED_CARRYING_CAPACITY=0xE6 /*230*/)
    capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug)
    burden   = InqInt(this, PROPERTY_INT_ENCUMBRANCE_VAL=5)      // default 0 if absent
    *loadOut = EncumbranceSystem::Load(capacity, burden)
    return 1   // always succeeds for CACQualities (has vtable)

This EXACTLY matches acdream's existing IndicatorBarController.UpdateBurden() / InventoryController.RefreshBurden() pattern (Strength attribute + prop 0xE6 aug + prop 5 EncumbranceVal, falling back to SumCarriedBurden when the wire value is absent) — already ported, already correct, already tested via the UI. AcDream.Core.Items.BurdenMath (EncumbranceCapacity/LoadRatio/LoadModifier) is the SAME formulas at the SAME addresses. P1's EncumbranceSystem (Physics-namespaced, for citation clarity next to MovementSystem) delegates to BurdenMath rather than re-deriving — one source of truth, no drift between the burden HUD and movement physics.

3. CanJump (0x00591b50, pc 412907) — X87 MUSH, POLARITY RESOLVED BY PLAUSIBILITY

CanJump(this, extent):
    load = 0
    if (InqLoad(this, &load) != 0):
        p = <fcompp load, 2.0f; fnstsw; test ah,0x05>   // "load < 2.0" per BN's own
                                                          // asserted C0 subexpression
        if (!p) return 1
    return 0

Literal BN reading: if (!p) return 1 = "if load is NOT < 2.0 (i.e. >= 2.0), return CAN-jump; otherwise CANNOT". That is backwards from every other retail-movement fact we have (LoadMod's own floor sits at 2.0; the campaign's connected-matrix acceptance is "≥200% barely moves/jumps", not "can only jump when overloaded"). This is the documented BN "bitfield mush" artifact class (feedback_bn_decomp_field_names.md) — the flag-synthesis is unreliable for the FOLLOWING test ah,mask interpretation even when the preceding subexpression is trustworthy.

Resolution (register row UN-8, see §6): CanJump returns load < 2.0 (can jump under 200% burden; refused at/above it) — the polarity a normal AC player's lived experience requires, and the one that makes CanJump's own threshold coincide with LoadMod's floor. ACE gives no tiebreaker (WeenieObject.CanJump is an unconditional return true stub — never ported burden gating at all). Ghidra MCP was down for this slice; flagged for a future confirmation pass, not blocking this port.

4. JumpStaminaCost (0x00591b90, pc 412949) — FULLY READABLE

CACQualities::JumpStaminaCost(this, extent, &costOut):
    load = 0
    if (InqLoad(this, &load) == 0) return 0
    pk = 0
    pkStatus = InqInt(this, PROPERTY_INT_PLAYER_KILLER_STATUS=0x86, default=8)
    if (pkStatus == 4 || pkStatus == 0x40):          // PK / PKLite
        pkTimestamp = InqFloat(this, PROPERTY_FLOAT_LAST_PK_ATTACK_TIMESTAMP=0x91)
        if (pkTimestamp is present && !(pkTimestamp + 20.0 < Timer::cur_time)):
            pk = 1                                    // PK timer active (<20s since last PK act)
    *costOut = MovementSystem::JumpStaminaCost(extent, load, pk)
    return 1        // ALWAYS true once InqLoad succeeds — no affordability
                     // check lives in this function.

Key finding: retail's CanQualities::JumpStaminaCost NEVER returns false (except when InqLoad itself fails, which doesn't happen for a real player). jump_is_allowed's if (!WeenieObj.JumpStaminaCost(...)) return 0x47 branch (the "refusal" path our own MotionInterpreter.cs already ports verbatim, W0-pins.md A2) is real retail machinery, but CACQualities never actually exercises the refusing side of it. "Refused jump" does not happen via this mechanism in retail — only "weak jump" (see §5). P1 ports JumpStaminaCost to always return true with the REAL computed cost (retiring the TS-5 zero-cost stub), matching this decomp exactly.

The pk flag is PlayerKillerStatus/LastPkAttackTimestamp — TS-23's exact scope (P3, not P1). P1 hardcodes pk: false at the one new call site (PlayerWeenie.JumpStaminaCost) and documents the dependency against TS-23 rather than re-implementing PK parsing here.

5. InqRunRate (0x00592800, pc 413824) / InqJumpVelocity (0x00592980, pc 413902) — FULLY READABLE

Both functions share one shape (Run uses skill id 0x18=24, Jump uses 0x16=22):

InqRunRate(this, &rateOut):
    load = 1.0
    if (InqLoad(this, &load) == 0) return 0

    currentStamina = 0
    if (AttributeCache::InqAttribute2nd(attribCache, ATTR2ND_STAMINA=4, &currentStamina) == 0)
        return 0
    EnchantAttribute2nd(this, 4, &currentStamina)      // vital-buff adjusts the LOCAL COPY only
                                                         // (not the wire "current stamina" state)

    skill = InqSkillBaseLevel(this, SKILL_RUN=0x18)     // formula-bonus + init + ranks
    skill += max(PropertyInt 0x16D, 0)                  // LumAugAllSkills
    skill += matching category augmentation ? 10 : 0   // 0x12C melee / 0x12D missile /
                                                         // 0x12E magic; exact skill-id switch
    EnchantSkill(this, 0x18, &skill)                    // vitae * skill-enchantments, floor@0.5, truncate
    if (PropertyInt 0x146 > 0) skill += 5              // Jack of All Trades
    if (skill is specialized) skill += 2 * max(PropertyInt 0x158, 0)

    if (currentStamina == 0) skill = 0                  // THE stamina-gates-movement mechanism

    *rateOut = MovementSystem::GetRunRate(load, skill, 1.0)
    return 1

InqJumpVelocity is identical but for skill id 0x16=22, and finishes with sqrt(MovementSystem::GetJumpHeight(load, skill, extent, 1.0) * 19.6) (pc 413975, matching GetJumpVZ's existing sqrt call already in MotionInterpreter.cs/PlayerWeenie.cs — unchanged).

Answering the plan's question — "which skill level does retail feed?" Neither raw base nor a separately-cached value: retail re-derives, on every query, EnchantSkill(baseSkill) where EnchantSkill (CEnchantmentRegistry:: EnchantSkill 0x005947b0, pc 416240, FULLY READABLE) is:

EnchantSkill(registry, skillId, &valueInOut):
    value = *valueInOut                       // base skill (formulaBonus+init+ranks)
    if (registry._vitae != null):
        value = Enchant(registry._vitae, value)      // vitae multiplier FIRST
    matching = CullEnchantmentsFromList(mult_list, category=SKILL=0x10, skillId)
             ++ CullEnchantmentsFromList(add_list,  category=SKILL=0x10, skillId)
    for each e in matching: value = Enchant(e, value)  // per-record mult OR add
    if (value < 0.5) value = 0                          // floor
    *valueInOut = (int)value                            // truncate (ftol2)
    return ...

CEnchantmentRegistry::EnchantAttribute2nd (0x00594670, pc 416169, the vitals path our EnchantmentMath.GetMod already ports for LocalPlayerState.GetMaxApprox) applies _vitae in the identical position (first, before the mult/add lists) — confirming our existing vitae representation (ActiveEnchantmentRecord.Bucket == 4, a StatModType Vitae flag 0x00800000 classified in GameEventWiring.ClassifyLiveEnchantmentBucket) is the right vehicle: P1 reuses it unmodified, adding a sibling EnchantmentMath.GetSkillMod (filtered by StatModType & Skill(0x10) != 0 instead of the vitals' implicit attribute2nd filter) rather than inventing a new vitae channel. This satisfies "vitae/enchant-adjusted effective run/jump skill... reading vitae + relevant skill enchantments from the M3 active-effect state" without a general effective-skill engine — the only new code is the type-flag filter and the skill-id key.

2026-07-31 #268 closeout: the previously bounded augmentation terms are now ported in shared PlayerSkillMath, after a complete read of CACQualities::InqSkill @ 0x00592660. The exact order matters:

  1. intrinsic formula/init/ranks;
  2. positive property 0x16D plus the exact category +10 switch;
  3. EnchantSkill;
  4. property 0x146 contributes +5 when positive;
  5. specialized skills receive 2 × max(property 0x158, 0).

The character panel and Runtime movement both consume this one Core calculation. AP-127 is retired. The apparent current-stamina-copy residual does not create an independently reachable effect for ordinary stat enchantments: current and maximum stamina use distinct secondary-attribute keys, and a max-stamina enchantment cannot turn zero current stamina nonzero.

6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+)

GetRunRate (0x006b0950) and the arg3!=0 (PK) branch of JumpStaminaCost (0x006b0a40) have their GENERAL-CASE arithmetic entirely dropped by BN (only the EncumbranceSystem::LoadMod/800-skill-compare calls and the arg3==0 ceil expression survive uncollapsed — the same information-loss class as the x87 mush, just total rather than partial). ACE is the cross-reference tiebreaker for those two spots (references/ACE/Source/ACE.Server/Physics/ Animation/MovementSystem.cs), matching this exact acdream port's ORIGINAL citation style (PlayerWeenie.cs's pre-P1 doc comments already said "decompiled + ACE MovementSystem" for these two formulas — nothing new here, just now with a named-decomp address alongside):

  • GetRunRate(load, skill, scaling) = skill==800 ? 18/4 : ((LoadMod(load) * (skill/(skill+200)*11) + 4) / scaling) / 4§12c correction (#266, 2026-07-30): the 800 branch is EXACT EQUALITY, not >=. Raw byte decode of 0x006b0950 (PDB-paired binary): fild skill; fcom [0x00803b94 = 800f]; fnstsw ax; test ah, 0x44; jp 0x6b097f — the C2/C3 parity idiom in which jp (general path) fires for <, >, AND unordered; the fld [18f]; fdiv [4f]; ret fall-through executes only when C3=1/C2=0, i.e. skill == 800 exactly. The general path decodes instruction-by-instruction to (LoadMod(load) * (skill/(skill+200)*11) + 4) / scaling / 4 (constants 200f @0x00803b8c, 11f @0x00803b88, 4f @0x007c6174, /scaling from [esp+0xc], final /4f @0x00803b80). ACE's >= 800 "max run speed?" reading is a misread of the same mush and must not be used as a tiebreaker here — it flat-lined every maxed character at 4.5 (retail general formula gives ~3.70) and erased the vitae speed differential (#266: 33%-vitae +Acdream visibly outran 5%-vitae +Je in acdream while retail runs them within ~0.4%). InqMaxRunRate's skill=9999 probe gets the general formula (~3.6961), not 4.5. The true retail signature carries a 3rd scaling arg (confirmed by the decomp's own function signature), and every known call site (InqMaxRunRate, InqRunRate) passes 1f.
  • GetJumpHeight(load, skill, extent, scaling) — BN's extent-clamp micro-branch (pc 006b09b0-006b09ca) is the SAME x87-mush pattern as §3; ACE's Math.Clamp(extent, 0, 1) is the tiebreaker (matches the EXISTING acdream code, which already does this — unchanged). = LoadMod(load) * (skill/(skill+1300)*22.2 + 0.05) * clampedExtent / scaling, floored at 0.35 — matches acdream's pre-existing formula exactly.
  • JumpStaminaCost(power, load, pk):
    • pk==0: ceil((load + 0.5) * power * 8 + 2)the campaign plan's own shorthand ("ceil((power+0.5)load8+2)") has the +0.5 term on the wrong operand; the verbatim decomp (fully readable, no mush) is (load + 0.5) * power, confirmed against ACE's identical (burden + 0.5f) * power.
    • pk!=0: BN drops the body entirely (bare _ftol2() tailcall, no operands survive); ACE's (int)((power + 1.0f) * 100.0f) is the tiebreaker. Unused by P1 (pk is hardcoded false — see §4), ported anyway for signature completeness/citation.
  • EncumbranceSystem::{EncumbranceCapacity, Load, LoadMod} (0x004fcc00/40/70, pc 256393+) — already verbatim-ported as AcDream.Core.Items.BurdenMath; P1's EncumbranceSystem delegates (see §2).

7. What "weak jump" actually is (no hard refusal exists)

Given §4 (JumpStaminaCost never refuses) and §5 (stamina==0 zeroes the EFFECTIVE skill, not the extent), the retail zero-stamina jump is: GetJumpHeight(load, skill=0, extent, 1) = LoadMod(load) * 0.05 * extent, floored to the 0.35 m minimum by the function's own clamp — i.e. every jump attempt, however exhausted, still produces at least the 0.35 m floor hop. There is no code path in CACQualities that makes jump_is_allowed return GeneralMovementFailure due to low stamina. The campaign plan's "weak/refused jump" acceptance phrasing is satisfied by "weak" (the floor hop); "refused" does not occur via burden/stamina in this chain and P1 does not invent it.

8. ReportExhaustion — wiring the dead R3-W4 seam

ReportExhaustion() (MotionInterpreter.cs:1619, already a full verbatim port of CMotionInterp::ReportExhaustion 0x005288d0) has ZERO callers anywhere in the codebase today. Retail's caller chain is CPhysicsObj::report_exhaustion (0x0050fdd0) → MovementManager::ReportExhaustion (0x00524360), both outside CMotionInterp's scope and not yet located precisely in the decomp (out of P1's bounded scope to hunt down the exact upstream trigger site). What we DO know precisely: its effect is "re-apply current movement through the SAME dual-dispatch predicate as apply_current_movement" — i.e. force a fresh WeenieObj.InqRunRate/InqJumpVelocity query against the CURRENT physics/interpreted state, with no new input event.

That is exactly the primitive needed to make a live burden/stamina/vitae change visible immediately (mid-run, mid-charge) instead of waiting for the next keypress. P1 wires ReportExhaustion() as the "re-evaluate movement now" trampoline any time Runtime pushes a fresh burden, stamina, or vitae-adjusted-skill value into the active PlayerMovementController — plausible given ReportExhaustion's documented purpose, and the least speculative real consumer available for a seam that otherwise never fires.

9. Design: where each input is computed and pushed

Runtime (AcDream.Runtime, presentation-free):
  RuntimeCharacterState
    - Spellbook (existing)               -- vitae + skill enchantments live here
    - MovementSkills: RuntimeMovementSkillState (existing, EXTENDED)
        RunSkill / JumpSkill             -- now the ADJUSTED (EnchantSkill'd) values
        Burden          (float, new)     -- InqLoad's load ratio
        CurrentStamina  (int, new, -1 sentinel = unknown/don't-gate)
    - _runSkillBase / _jumpSkillBase (new, private) -- pre-EnchantSkill values
    - UpdateMovementSkillBase(runBase, jumpBase)     -- stores base, recomputes+pushes adjusted
    - RecomputeMovementSkills()          -- base * EnchantmentMath.GetSkillMod(skillId), floor/round
    - wired: Spellbook.EnchantmentsChanged -> RecomputeMovementSkills (vitae/buff changes
      recompute WITHOUT a fresh PD skill push)

  LiveSessionEventRouter.Attach()  (cross-owner wiring hub; already the home
  of the existing onSkillsUpdated -> MovementSkills.Update plumbing)
    - onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
    - NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
      ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
      -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
         SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
      -> character.Character.MovementSkills.UpdateBurden(ratio)
    - NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
      -> character.Character.MovementSkills.UpdateStamina(current)
    - all three trigger points additionally invoke the new
      LiveCharacterSessionBindings.OnMovementStatsUpdated callback

  RuntimeMovementSkillProjection.ApplyTo(skills, controller)  (existing seam,
  called at construction AND reactively from OnSkillsUpdated/OnMovementStatsUpdated)
    - SetCharacterSkills(run, jump)  (existing)
    - NEW: SetCharacterBurden(burden), SetCharacterStamina(stamina)

App (LiveSessionRuntimeFactory) / Headless (HeadlessSessionHost):
    - OnMovementStatsUpdated: App wires ApplyTo(...) + controller.Motion.ReportExhaustion()
      (mirrors the existing OnSkillsUpdated body, which P1 ALSO extends with
      the ReportExhaustion() call for consistency); Headless passes null,
      matching its existing OnSkillsUpdated: null (headless bots don't need
      live mid-session re-apply to a controller that may not exist yet).

Core (AcDream.Core.Physics, presentation-free, pure):
  EncumbranceSystem  -- EncumbranceCapacity/Load/LoadMod, delegates to BurdenMath
  MovementSystem     -- GetRunRate/GetJumpHeight/JumpStaminaCost/GetJumpPower
  PlayerWeenie (CACQualities-shaped)
    _burden (float), _currentStamina (int?, null=unknown) -- pushed via
      SetBurden/SetStamina (SetBurden already existed, wires the dead setter)
    _runSkill/_jumpSkill (int)  -- pushed via SetSkills, ALREADY vitae/enchant-
      adjusted by Runtime before it arrives here (PlayerWeenie itself stays
      a pure formula consumer -- no Spellbook/enchantment dependency, keeping
      it trivially testable)
    CanJump(extent)          -> _burden < 2.0  (UN-8 polarity, §3)
    JumpStaminaCost(extent, out cost)
                              -> cost = MovementSystem.JumpStaminaCost(extent, _burden, pk:false);
                                 return true;   (§4 -- always true, TS-23 owns pk)
    InqRunRate(out rate)     -> effSkill = _currentStamina == 0 ? 0 : _runSkill;
                                 rate = MovementSystem.GetRunRate(_burden, effSkill, 1f);
    InqJumpVelocity(extent, out vz)
                              -> effSkill = _currentStamina == 0 ? 0 : _jumpSkill;
                                 vz = sqrt(MovementSystem.GetJumpHeight(_burden, effSkill, extent, 1f) * 19.6f);

_currentStamina == null (never set — matches every existing test / call site that doesn't call SetStamina) never zeroes the skill, preserving every pre-P1 PlayerWeenieTests.cs expectation unchanged.

10. Register bookkeeping (same commit as the port)

  • Delete TS-5 (CanJump always true / JumpStaminaCost zero-cost stub) — retired: both now real, decomp-cited.
  • Delete AP-25 (run/jump skill = attributeBonus+init+ranks only, no vitae) — retired: vitae now flows through EnchantmentMath.GetSkillMod.
  • TS-21 untouched — still valid (pre-PD fallback defaults 200/300 are a separate divergence, not addressed by P1).
  • TS-23 extended (not a new row) — its "PlayerKillerStatus not parsed" scope now also covers the new MovementSystem.JumpStaminaCost pk parameter, hardcoded false at the PlayerWeenie call site pending P3.
  • AP-127 retired 2026-07-31 (#268) — the complete 0x16D/category/ 0x146/0x158 chain is shared by panel and movement (§5 closeout).
  • New UN-8CACQualities::CanJump's x87 comparison polarity resolved by domain plausibility rather than a literal BN read (§3); Ghidra MCP confirmation is the retire path.

11. Test plan

  • MovementSystemTests (new, Core): golden tables for GetRunRate (0/200/ 800 skill, load knees), GetJumpHeight (extent 0/0.5/1, 0.35 floor, load knees), JumpStaminaCost (ceil rounding, load/power sweep), GetJumpPower (inverse sanity, not consumed by P1 but ported for signature completeness / future charge-meter work).
  • EncumbranceSystemTests (new, Core): capacity at 100%/200% aug clamp, Load ratio, LoadMod knees — cross-checked 1:1 against the EXISTING BurdenMath tests (same formulas, must agree bit-for-bit).
  • PlayerWeenieTests (extend): CanJump refusal at load>=2.0 / allowed below; JumpStaminaCost real nonzero cost; InqRunRate/InqJumpVelocity zero at stamina==0 (skill forced to 0, still floors at 0.35 m for jump); ALL pre-existing tests must stay green unmodified (no SetStamina call -> null sentinel -> no gating, exactly today's behavior).
  • EnchantmentMathTests (extend): GetSkillMod type-flag filtering (Skill-flagged records match; vital-only records with a colliding numeric key do NOT), vitae-first-then-mult-then-add ordering.
  • RuntimeCharacterStateTests / RuntimeMovementSkillStateTests (Runtime): burden/stamina push + revision bump; RecomputeMovementSkills fires on Spellbook.EnchantmentsChanged without a fresh base push; ResetSession convergence includes Burden==0/CurrentStamina==-1.
  • LiveSessionEventRouterTests (Runtime, if a harness exists) or a focused new test: ObjectTable burden-trigger events recompute and push burden; Stamina vital change pushes CurrentStamina.

12. P1 Opus-review addenda (2026-07-30, post-implementation)

12a. UN-8 RETIRED — CanJump polarity byte-proven

Raw bytes of CACQualities::CanJump @ 0x00591b50 in the PDB-paired v11.4186 binary (technique: reference_pe_byte_decode):

e8 ca d5 ff ff   call InqLoad (0x0058f130)
85 c0 / 74 1a    test eax,eax; jz return0     ; load unknowable -> 0
d9 44 24 00      fld  dword [esp]             ; st0 = load
d8 1d 24 5e 7c 00 fcomp dword [0x007c5e24]    ; vs 2.0f (verified read)
df e0            fnstsw ax
f6 c4 05         test ah, 0x05                ; C0|C2
7a 09            jp   return0                 ; PF=1 on {neither, both}
b8 01 00 00 00   mov  eax, 1                  ; fall-through: C0 only

test ah,5 result parity: 0x00 (load ≥ 2.0, incl. ==) → PF=1 → 0; 0x01 (load < 2.0) → PF=0 → 1; 0x05 (unordered) → PF=1 → 0. CanJump = (load < 2.0f); NaN/unordered refuses. The shipped _burden < CanJumpLoadThreshold matches exactly, including the NaN edge.

12b. PK-timer jump-cost semantics (for Slice P3 / TS-23)

CACQualities::JumpStaminaCost @ 0x00591b90 (pc 412934-412968), fully readable: the pk flag passed to MovementSystem::JumpStaminaCost is

pk = InqInt(0x86 /*134 PlayerKillerStatus*/, default 8) in {4 /*PK*/, 0x40 /*PKLite*/}
     && InqFloat(0x91 /*145*/) succeeded
     && (that_float + 20.0) >= Timer::cur_time

i.e. PK/PKLite status AND a 20-second recency window on PropertyFloat 0x91. The P3 implementer should plumb exactly this pair alongside the mover-flag work; MovementSystem.JumpStaminaCost's pk branch ((int)((power + 1) * 100), ACE-derived — the retail branch is an elided _ftol2 tailcall) is already in place.