Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain (named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so PlayerWeenie's run rate, jump height, jump permission, and jump stamina cost are real functions of burden, current stamina, and vitae/skill enchantments instead of stubs. Core: - New EncumbranceSystem.cs (delegates to the already-verified BurdenMath formulas — one source of truth for the burden HUD and movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/ JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced where BN dropped the general-case arithmetic entirely). - PlayerWeenie rewritten as the CACQualities-shaped composition: CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost returns the real ceil((load+0.5)*power*8+2) cost and always affords it (matches decomp — retail's own function never refuses; "weak" jump comes entirely from the stamina==0 skill-zeroing gate inside InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a null="unknown, don't gate" sentinel preserving every pre-P1 test. - EnchantmentMath.GetMod gained an optional StatModType flag filter (GetSkillMod convenience wrapper) so the SAME vitae/family-stacking machinery already used for vital-max buffs now also answers "what's the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the M3 active-enchantment state, not a new engine. Runtime: - RuntimeCharacterState now stores the pre-EnchantSkill base run/jump skill and recomputes the adjusted value (vitae first, then matching Skill-flagged buffs, floor 0.5, truncate) on every base push AND on every Spellbook.EnchantmentsChanged notification — a vitae change alone moves the produced rate without a fresh PlayerDescription. - RuntimeMovementSkillState extended with Burden/CurrentStamina (RuntimeMovementSkillProjection.ApplyTo pushes both through the existing seam); LiveSessionEventRouter recomputes burden from the same Strength+aug-property+EncumbranceVal inputs the burden HUD already assembles (reacting to the same ClientObjectTable events) and pushes current stamina from LocalPlayerState vital updates. - Wires the previously dead-lettered ReportExhaustion() R3-W4 seam: LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re- applies the current snapshot to the live controller and forces an immediate movement re-evaluation on any skill/burden/stamina change. Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus properties + the stamina-buff-adjusts-local-copy nuance, deliberately out of the bounded "run/jump query path only" scope) and UN-8 (the CanJump x87 polarity call, flagged for a future Ghidra MCP confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to cover JumpStaminaCost's new pk parameter, hardcoded false pending P3. Full pseudocode + retail citations + the vitae/skill-level finding in docs/research/2026-07-30-stat-coupled-movement-pseudocode.md. Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips, App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug- only flake in LandblockBuildOriginTests reproduces on the pre-P1 baseline and passes in Release; not touched here.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21 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.