All seven review lenses pass. CanJump's polarity is upgraded from
plausibility to proof: raw bytes of 0x00591b50 show fld load / fcomp
[0x007c5e24 = 2.0f] / test ah,5 / jp -> return 0, i.e. return 1 iff
load < 2.0 with unordered refusing - exactly the shipped code, NaN edge
included. UN-8 deleted. CACQualities::JumpStaminaCost's pk flag decoded
for P3: PlayerKillerStatus in {4,0x40} AND PropertyFloat 0x91 + 20 s >=
now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 KiB
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, ¤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) // base: formula-bonus + init + ranks
// (+ two minor bonus properties 0x146,
// 0x158 — NOT ported, see §6 AP-127)
EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, round
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.
Two things P1 deliberately does NOT port (bounded scope, register row AP-127):
- Two minor additive skill-bonus properties inside
InqSkillBaseLevel's surrounding block (property0x146"> 0 → +5", property0x158"specialized-skill → double a PP-derived term") — small, rare bonuses unrelated to burden/stamina/vitae. EnchantAttribute2nd's buff-adjustment of the LOCAL stamina-current copy used only for the== 0gate (i.e. a Stamina-boosting buff could theoretically keep that local copy above 0 even at true-zero wire stamina). We gate on the raw wire "current stamina" value directly.
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-cap 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— matches acdream's pre-existingPlayerWeenie.GetRunRateexactly (which hardcodedscaling=1); the true retail signature carries a 3rdscalingarg (confirmed by the decomp's own function signature), and every known call site (InqMaxRunRate,InqRunRate) passes1f— so porting the full signature is free (no behavior change), just closer to the retail surface for future callers.GetJumpHeight(load, skill, extent, scaling)— BN's extent-clamp micro-branch (pc 006b09b0-006b09ca) is the SAME x87-mush pattern as §3; ACE'sMath.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.5term 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 (pkis hardcodedfalse— see §4), ported anyway for signature completeness/citation.
EncumbranceSystem::{EncumbranceCapacity, Load, LoadMod}(0x004fcc00/40/70, pc 256393+) — already verbatim-ported asAcDream.Core.Items.BurdenMath; P1'sEncumbranceSystemdelegates (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 (
CanJumpalways true /JumpStaminaCostzero-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.JumpStaminaCostpkparameter, hardcodedfalseat thePlayerWeeniecall site pending P3. - New AP-127 — two minor retail skill-bonus properties (0x146, 0x158) and the stamina-buff-adjusts-local-copy nuance are not ported (§5, §9 bullet list) — bounded, deliberate, low-risk (rare bonus terms, not burden/stamina/vitae).
- 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 forGetRunRate(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 EXISTINGBurdenMathtests (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):GetSkillModtype-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;RecomputeMovementSkillsfires onSpellbook.EnchantmentsChangedwithout 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.