# 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 ```c 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) ``` The property/capacity shape matches acdream's `IndicatorBarController.UpdateBurden()` / `InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire value is absent). A 2026-07-31 connected gate exposed one omitted retail detail: `InqAttribute` returns the enchantment-adjusted attribute, while all three acdream burden consumers still read raw `AttributeValue.Current`. Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)` and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge. **`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 ```c CanJump(this, extent): load = 0 if (InqLoad(this, &load) != 0): p = // "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 ```c 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): ```c InqRunRate(this, &rateOut): load = 1.0 if (InqLoad(this, &load) == 0) return 0 currentStamina = 0 if (AttributeCache::InqAttribute2nd(attribCache, ATTR2ND_STAMINA=4, ¤tStamina) == 0) return 0 EnchantAttribute2nd(this, 4, ¤tStamina) // 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: ```c 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)*load*8+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) + Spellbook.EnchantmentsChanged -> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal, SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden) using effective/enchantment-adjusted Strength -> 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-8** — `CACQualities::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.