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>
440 lines
24 KiB
Markdown
440 lines
24 KiB
Markdown
# 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)
|
|
```
|
|
|
|
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
|
|
|
|
```c
|
|
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
|
|
|
|
```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) // 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:
|
|
|
|
```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.
|
|
|
|
**Two things P1 deliberately does NOT port** (bounded scope, register row
|
|
AP-127):
|
|
1. Two minor additive skill-bonus properties inside `InqSkillBaseLevel`'s
|
|
surrounding block (property `0x146` "> 0 → +5", property `0x158`
|
|
"specialized-skill → double a PP-derived term") — small, rare bonuses
|
|
unrelated to burden/stamina/vitae.
|
|
2. `EnchantAttribute2nd`'s buff-adjustment of the LOCAL stamina-current copy
|
|
used only for the `== 0` gate (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-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)
|
|
-> 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.
|
|
- **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 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.
|