feat(physics): Campaign P P1 - stat-coupled movement (burden/stamina/vitae)

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>
This commit is contained in:
Erik 2026-07-30 08:18:06 +02:00
parent 3a4782048e
commit 9355ddcec6
20 changed files with 1714 additions and 74 deletions

View file

@ -1,4 +1,4 @@
# Retail Divergence Register — current through 2026-07-27 # Retail Divergence Register — current through 2026-07-30
**What this is.** The single auditable register of every known place acdream's **What this is.** The single auditable register of every known place acdream's
runtime behavior can deviate from the retail client (Sept 2013 EoR build, runtime behavior can deviate from the retail client (Sept 2013 EoR build,
@ -114,7 +114,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 93 active rows ## 3. Documented approximation (AP) — 93 active rows (AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -142,7 +142,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions``CPartArray::FindObjCollisions` pc:286236 |
| AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | | AP-23 | Invented per-type pickup-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating plus the speculative local TurnToObject/MoveToObject install through the player's MoveToManager. **R5-V3 narrowed it:** the install threads the target's real Setup radius/height (`GetSetupCylinder`, same as wire mt-6) and the player's real radius; only the radius buckets remain invented. **Use retired from this seam 2026-07-25** and now sends immediately. | `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`TryGetApproach`/`GetUseRadius`); `src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs` (`BeginApproach`) | The retained pickup presentation reserves a destination slot before the authoritative transfer; its close branch still needs an arrival boundary | A target whose real UseRadius differs from the bucket misjudges the pickup gate — pickup waits forever or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b |
| ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` | | ~~AP-24~~ | **RETIRED 2026-07-11** — matching v11.4186 x86 disassembly recovered `ATTACK_POWERUP_TIME=1.0` seconds and `DUAL_WIELD_POWERUP_TIME=0.8` seconds from the operands loaded by `GetPowerBarLevel`; jump and combat now share those constants. | `src/AcDream.Core/Combat/CombatModel.cs`; `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs` | — | — | `ClientCombatSystem::GetPowerBarLevel @ 0x0056ADE0`; static data `0x007CEFC8/0x007CEFD0` |
| AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) |
| AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 |
| AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 |
| AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | | AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 |
@ -230,14 +229,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` | | AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` |
| AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` | | AP-125 | Transport control packets (the 2.0 s cumulative AckSequence and the 0.6 s RequestRetransmit) are emitted STANDALONE; retail piggybacks optional headers onto queued outbound packets first-fit (`FlowQueue::CoalesceData @ 0x00547740`, invoked at `TransmitNewPackets @ 0x00547A6E`), and `EnqueueNaks` hands the NAK to `PacketController::EnqueueOptionalHeader @ 0x00543C84` rather than emitting directly. | `src/AcDream.Core.Net/Transport/AckNakScheduler.cs` (`EmitCumulativeAck`, `EmitNakRequest`) | ACE honours a RequestRetransmit ONLY when EncryptedChecksum is absent (NetworkSession.cs:283-284) — a retail-style piggyback onto a sequenced packet encrypts the NAK and ACE silently ignores it, making S2C loss unrecoverable; ACE likewise advances its client-sequence watermark on any packet whose flags are not exactly AckSequence (:474-476), so coalesced control content on a borrowed sequence risks skipping a real packet. Standalone exact-flag emission is the only ACE-safe shape; it also keeps reliable packets free of optional headers, making the resend cache strip provably a no-op. | Slightly higher C2S datagram count than retail (one extra small packet per 2.0 s / per NAK window); marginally more loss exposure for the control packets themselves on a metered path. | `FlowQueue::CoalesceData @ 0x00547740`; `SharedNet::EnqueuePak @ 0x00543B10`; `SharedNet::EnqueueNaks @ 0x00543BD0`; ACE `NetworkSession.cs:283-284,:342-343,:474-476` |
| AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) |
| AP-127 | Campaign P Slice P1's run/jump base-skill chain omits two minor retail additive/multiplier terms feeding `CACQualities::InqRunRate`/`InqJumpVelocity` BEFORE `EnchantSkill` runs (property `0x146` "> 0 → +5" bonus; property `0x158` "specialized skill" doubling of a PP-derived term), and reads the raw wire current-stamina value for the zero-skill gate rather than the retail-adjusted local copy (`EnchantAttribute2nd(ATTR2ND_STAMINA)` can apply a Stamina-buff to that check's own copy without changing the displayed vital) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`ApplySkillEnchantments`); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`InqRunRate`/`InqJumpVelocity` stamina==0 gate) | Bounded per the P1 plan's explicit scope ("port only what the run/jump query path needs... not a general effective-skill engine"); both terms are rare/small relative to the dominant formulaBonus+init+ranks+vitae chain, which IS fully ported | A character with the specific rare property set (0x146/0x158) or an active Stamina-buff at exactly 0 raw stamina predicts a slightly different run/jump skill than retail; low practical impact | `CACQualities::InqRunRate` 0x00592800 pc 413824 (0x146/0x158 reads); `CEnchantmentRegistry::EnchantAttribute2nd` 0x00594670 pc 416169 |
## 4. Temporary stopgap (TS) — 43 active rows (TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) ## 4. Temporary stopgap (TS) — 42 active rows (TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain | | TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain |
| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 |
| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 |
| TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) |
| TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 |
| TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | | TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs |
@ -249,7 +248,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) |
| ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16**`CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` | | ~~TS-20~~ | **RETIRED AS A FALSE ATTRIBUTION 2026-07-16**`CGfxObj::InitLoad` passes the complete polygon array to `D3DPolyRender::ConstructMesh`; ordinary GfxObj rendering does not filter it through DrawingBSP. Building DrawingBSP traversal discovers and orders portal apertures after `RemoveNonPortalNodes`; it is not a global visible-polygon selector. The alleged building-shell "orphans" are `DrawingBSPNode.Portals`, omitted by the old diagnostic collector; the corrected node-polygons portal-polygons audit finds no true orphans. Applying the proposed filter would repeat the door disappearance regression from `e46d3d9`. | `docs/research/2026-06-11-holistic-map/wf1-gfxobj-draw.md`; `docs/research/2026-06-11-holistic-map/wf1-building-shells.md`; `tests/AcDream.Core.Tests/Rendering/Wb/Issue113DoorVanishDiagnosticTests.cs` | — | — | `CGfxObj::InitLoad @ 0x005346B0`; `D3DPolyRender::ConstructMesh @ 0x0059DFA0`; `BSPTREE::build_draw_portals_only @ 0x00539860` |
| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | | TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 |
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) | | TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover. Campaign P Slice P1 (2026-07-30) added a THIRD consumer of this same gap: `MovementSystem.JumpStaminaCost`'s `pk` parameter (retail `CACQualities::JumpStaminaCost` reads PlayerKillerStatus property 0x86 + LastPkAttackTimestamp property 0x91) is hardcoded `false` at the `PlayerWeenie` call site pending this row's fix | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch); `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost`, P1) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail). P1's jump-stamina-cost `pk:false` is harmless pre-PK (the PK-timer-active cost bump never applies to a non-PK character anyway) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+); a PK-active character's jump stamina cost also undercounts vs retail's `(power+1.0)*100.0` PK-timer formula until this row is fixed | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`); `CACQualities::JumpStaminaCost` 0x00591b90 pc 412949 |
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | | TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 1115) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | | TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
| TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` | | TS-27 | **NARROWED 2026-07-29 (Campaign N Slice N1)** — OUTBOUND is ported: sent-packet cache + header-rebuilt resend on server `RequestRetransmit`, `ids[0]` implicit ack, wrap-safe watermark prune (`src/AcDream.Core.Net/Transport/`). Residual: INBOUND loss is still fatal — no sequence-aligned inbound ISAAC discipline, no client NAK emission, no `RejectRetransmit` consumption (Campaign N slices N2/N4) | `src/AcDream.Core.Net/WorldSession.cs` (`ProcessDatagram` inbound path); `docs/plans/2026-07-29-network-transport-campaign.md` §2.2/§2.3 | Campaign N executes the port one direction per slice; the N0 ACE double grades each slice before the next lands | One lost S2C packet still shifts the inbound keystream permanently — every later encrypted packet fails checksum and the session goes silently deaf until timeout | `SharedNet::ProcessPacket @ 0x00544790`; `ReceiverData::AddNakked @ 0x00549240`; `SharedNet::EnqueueNaks @ 0x00543BD0` |
@ -285,7 +284,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
--- ---
## 5. Unclear (UN) — 4 rows ## 5. Unclear (UN) — 5 rows (UN-8 filed 2026-07-30 at Campaign P Slice P1)
These rows have a missing, contradictory, or never-argued justification. These rows have a missing, contradictory, or never-argued justification.
They are the highest-priority audits: each needs either a recorded They are the highest-priority audits: each needs either a recorded
@ -297,6 +296,7 @@ equivalence argument (promote to AD/AP) or a fix.
| UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 |
| UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) |
| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | | UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)``1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md |
| UN-8 | `CACQualities::CanJump`'s (0x00591b50) x87-mush comparison against the 2.0-load threshold was resolved by DOMAIN PLAUSIBILITY, not a literal BN read — Campaign P Slice P1 (2026-07-30) ported `load < 2.0` (can jump under 200% burden), the polarity a normal AC player's experience requires and that coincides with `LoadMod`'s own floor, over BN's literal (backwards) reading. ACE gives no tiebreaker (its `WeenieObject.CanJump` is an unconditional `true` stub); Ghidra MCP was unavailable this slice | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`CanJump`, `CanJumpLoadThreshold`) | Plausibility argument recorded in the pseudocode doc §3, not a verified decompile; the exact x87 flag-synthesis for the FOLLOWING `test ah,mask` interpretation is the documented BN "bitfield mush" artifact class | If the polarity is actually backwards, CanJump refuses jumps under 200% load (breaking ordinary play) instead of only refusing severe overload — would surface immediately in the P1 visual matrix scenario 1 (barely moves/jumps near 200%) | `CACQualities::CanJump` 0x00591b50 pc 412907; `docs/research/2026-07-30-stat-coupled-movement-pseudocode.md` §3 |
--- ---
@ -318,7 +318,7 @@ WITH that phase, not before.
9. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. 9. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging.
**Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):**
M2 combat must land TS-5 (CanJump gating), TS-23 (PK bits), TS-25 M2 combat must land TS-23 (PK bits), TS-25
(stance in MoveToState), TS-17 (AttackConditions), (stance in MoveToState), TS-17 (AttackConditions),
and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the
0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing).

View file

@ -0,0 +1,387 @@
# 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, &currentStamina) == 0)
return 0
EnchantAttribute2nd(this, 4, &currentStamina) // vital-buff adjusts the LOCAL COPY only
// (not the wire "current stamina" state)
skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // 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-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-existing `PlayerWeenie.GetRunRate` exactly (which
hardcoded `scaling=1`); 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` — 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'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.

View file

@ -275,25 +275,45 @@ internal sealed class LiveSessionRuntimeFactory
_domain.Actions.Combat, _domain.Actions.Combat,
_domain.Character, _domain.Character,
ResolveSkillFormulaBonus: skillCreditResolver.Resolve, ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) => OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"),
{
if (RuntimeMovementSkillProjection.ApplyTo(
_domain.Character.MovementSkills,
_player.Controller.Controller))
{
RuntimeMovementSkillSnapshot snapshot =
_domain.Character.MovementSkills.Snapshot;
_log(
$"player: applied server skills " +
$"run={snapshot.RunSkill} " +
$"jump={snapshot.JumpSkill}");
}
},
OnConfirmationRequest: request => OnConfirmationRequest: request =>
_ui.RetailUi?.HandleConfirmationRequest(request), _ui.RetailUi?.HandleConfirmationRequest(request),
OnConfirmationDone: done => OnConfirmationDone: done =>
_ui.RetailUi?.HandleConfirmationDone(done), _ui.RetailUi?.HandleConfirmationDone(done),
ClientTime: ClientTimerNow); ClientTime: ClientTimerNow,
// Campaign P Slice P1 (2026-07-30): burden/stamina/vitae changes
// reactively re-apply to the live controller through the SAME
// seam skills already used, then wire the previously-dead
// ReportExhaustion() R3-W4 seam so movement re-evaluates
// immediately (pseudocode doc §8/§9).
OnMovementStatsUpdated: () => ApplyMovementStats("stats"));
}
/// <summary>
/// Re-applies the current <see cref="RuntimeMovementSkillState"/>
/// snapshot (skills/burden/stamina) to the live player controller and
/// forces an immediate movement re-evaluation via
/// <c>MotionInterpreter.ReportExhaustion</c> — the retail
/// <c>CMotionInterp::ReportExhaustion</c> dual-dispatch re-apply, now
/// wired to a real consumer (Campaign P Slice P1).
/// </summary>
private void ApplyMovementStats(string reason)
{
PlayerMovementController? controller = _player.Controller.Controller;
if (!RuntimeMovementSkillProjection.ApplyTo(
_domain.Character.MovementSkills,
controller))
{
return;
}
controller!.Motion.ReportExhaustion();
RuntimeMovementSkillSnapshot snapshot = _domain.Character.MovementSkills.Snapshot;
_log(
$"player: applied server movement {reason} "
+ $"run={snapshot.RunSkill} jump={snapshot.JumpSkill} "
+ $"burden={snapshot.Burden:F2} stamina={snapshot.CurrentStamina}");
} }
private LiveSessionCommandBindings CreateCommandBindings( private LiveSessionCommandBindings CreateCommandBindings(

View file

@ -0,0 +1,43 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>EncumbranceSystem</c> (named-retail decomp
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> pc 256393),
/// the pure load/capacity math consumed by <see cref="MovementSystem"/> and
/// <see cref="PlayerWeenie"/>'s <c>InqLoad</c>-equivalent chain.
///
/// <para>
/// These are the SAME three formulas <c>AcDream.Core.Items.BurdenMath</c>
/// already ports (verified against the identical retail addresses, used by
/// the burden HUD / character sheet / indicator bar). Rather than re-derive
/// them a second time under a different name, this class delegates —
/// P1 (2026-07-30, Campaign P) needs the retail-named surface next to
/// <see cref="MovementSystem"/> for citation clarity, but a single formula
/// implementation keeps the burden HUD and movement physics from drifting
/// apart. See <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c>
/// §2 and §6.
/// </para>
/// </summary>
public static class EncumbranceSystem
{
/// <summary>
/// <c>EncumbranceSystem::EncumbranceCapacity</c> 0x004fcc00:
/// <c>strength &lt;= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength</c>.
/// </summary>
public static int EncumbranceCapacity(int strength, int aug) =>
AcDream.Core.Items.BurdenMath.EncumbranceCapacity(strength, aug);
/// <summary>
/// <c>EncumbranceSystem::Load</c> 0x004fcc40: <c>burden / capacity</c>
/// (1.0 = at capacity). Returns 0 when capacity &lt;= 0 (no-data).
/// </summary>
public static float Load(int capacity, int burden) =>
AcDream.Core.Items.BurdenMath.LoadRatio(capacity, burden);
/// <summary>
/// <c>EncumbranceSystem::LoadMod</c> 0x004fcc70: full effectiveness
/// through 100% load, linear falloff to zero at 200%, zero above it.
/// </summary>
public static float LoadMod(float load) =>
AcDream.Core.Items.BurdenMath.LoadModifier(load);
}

View file

@ -0,0 +1,98 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>MovementSystem</c> (named-retail decomp pc 695958+): the pure
/// formula layer <c>PlayerWeenie</c> (the CACQualities-shaped composition)
/// calls once burden, skill, and stamina inputs are assembled. See
/// <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c> §6.
///
/// <para>
/// <b>x87-mush disclosure:</b> <see cref="GetRunRate"/>'s general-case
/// arithmetic and <see cref="JumpStaminaCost"/>'s <c>pk!=0</c> branch are
/// entirely dropped by the BN decompiler (not partially garbled — the
/// operand expressions never survive translation, unlike the polarity-only
/// ambiguities elsewhere in this port). Both are cross-referenced against
/// <c>references/ACE/Source/ACE.Server/Physics/Animation/MovementSystem.cs</c>,
/// which already matched this exact acdream port's PRE-P1 code
/// (<c>PlayerWeenie.GetRunRate</c>/<c>GetJumpHeight</c> already cited "decompiled
/// + ACE MovementSystem" before this slice) — no behavior change for the
/// formulas already live; only the retail-named surface and the 3rd/4th
/// <c>scaling</c> parameters (every known call site passes <c>1f</c>) are new.
/// </para>
/// </summary>
public static class MovementSystem
{
/// <summary>
/// <c>MovementSystem::GetRunRate</c> 0x006b0950. Retail-verified 800-skill
/// cap (<c>InqMaxRunRate</c> passes skill=9999 to reach this cap); general
/// case ACE-cross-referenced (BN dropped the arithmetic, see class doc).
/// </summary>
public static float GetRunRate(float burden, int runSkill, float scaling = 1f)
{
if (runSkill >= 800)
return 18f / 4f;
float loadMod = EncumbranceSystem.LoadMod(burden);
return ((loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / scaling) / 4f;
}
/// <summary>
/// <c>MovementSystem::GetJumpHeight</c> 0x006b09b0. Fully readable except
/// the extent-clamp micro-branch (x87 mush, ACE's <c>Math.Clamp(power,0,1)</c>
/// is the tiebreaker — matches the pre-P1 acdream port unchanged).
/// </summary>
public static float GetJumpHeight(
float burden,
int jumpSkill,
float power,
float scaling = 1f)
{
power = Math.Clamp(power, 0f, 1f);
float loadMod = EncumbranceSystem.LoadMod(burden);
float result = loadMod
* ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f)
* power
/ scaling;
return result < 0.35f ? 0.35f : result;
}
/// <summary>
/// <c>MovementSystem::JumpStaminaCost</c> 0x006b0a40. The <c>pk==0</c>
/// branch is fully readable: <c>ceil((load + 0.5) * power * 8 + 2)</c> —
/// note <c>load</c> (not <c>power</c>) carries the <c>+0.5</c>; the
/// campaign plan's shorthand had the operands swapped (see pseudocode
/// doc §6). The <c>pk!=0</c> branch is entirely dropped by BN; ACE's
/// <c>(power+1.0)*100.0</c> is the tiebreaker. <paramref name="pk"/> is
/// hardcoded <c>false</c> at every P1 call site pending TS-23
/// (PlayerKillerStatus parsing, Campaign P Slice P3) — ported here for
/// signature completeness only.
/// </summary>
public static int JumpStaminaCost(float power, float burden, bool pk)
{
if (pk)
return (int)((power + 1.0f) * 100.0f);
return (int)Math.Ceiling((burden + 0.5f) * power * 8f + 2f);
}
/// <summary>
/// <c>MovementSystem::GetJumpPower</c> — the algebraic inverse of
/// <see cref="JumpStaminaCost"/>, solving for the extent affordable at a
/// given stamina. Present in ACE (uncommented, live utility) but its
/// retail call site is the charge-power-meter UI outside
/// <c>CMotionInterp</c> (0x0056afac, out of R3/P1 scope — see
/// <c>ChargeJump</c>'s doc comment in <c>MotionInterpreter.cs</c>). Not
/// consumed by P1; ported for signature completeness and to leave the
/// formula available for the charge-meter follow-up without a second
/// decomp pass.
/// </summary>
public static float GetJumpPower(uint stamina, float burden, bool pk)
{
if (pk)
return stamina / 100.0f - 1.0f;
return (stamina - 2.0f) / (burden * 8.0f + 4.0f);
}
}

View file

@ -1,18 +1,56 @@
namespace AcDream.Core.Physics; namespace AcDream.Core.Physics;
/// <summary> /// <summary>
/// IWeenieObject implementation for the local player. Provides skill-based /// IWeenieObject implementation for the local player — the C# analogue of
/// run rate and jump velocity calculations. /// retail's <c>CACQualities</c> "qualities DB" composition (named-retail
/// decomp pc 412901-414050; ACCWeenieObject's own CanJump/JumpStaminaCost/
/// InqRunRate/InqJumpVelocity/InqMaxRunRate are thin delegations to exactly
/// this object, gated on <c>IsThePlayer()</c> — pc 406512+, confirming this
/// query family only ever reaches the LOCAL player's weenie).
/// ///
/// Formulas from decompiled acclient.exe, cross-referenced against /// <para>
/// ACE MovementSystem.GetRunRate and MovementSystem.GetJumpHeight. /// Campaign P Slice P1 (2026-07-30): burden, current stamina, and the
/// vitae/enchantment-adjusted run/jump skill are now real, pushed inputs —
/// see <c>docs/research/2026-07-30-stat-coupled-movement-pseudocode.md</c>.
/// <c>_runSkill</c>/<c>_jumpSkill</c> arrive ALREADY <c>EnchantSkill</c>-adjusted
/// (vitae + skill enchantments folded in by
/// <c>AcDream.Runtime.Gameplay.RuntimeCharacterState.RecomputeMovementSkills</c>)
/// — this class stays a pure formula consumer with no Spellbook/enchantment
/// dependency, matching retail's OWN split between <c>CACQualities</c> (the
/// query surface) and <c>CEnchantmentRegistry</c> (the buff aggregator it
/// calls into internally).
/// </para>
///
/// <para>
/// Formulas from decompiled acclient.exe (<see cref="MovementSystem"/> /
/// <see cref="EncumbranceSystem"/>), cross-referenced against ACE
/// <c>MovementSystem.GetRunRate</c>/<c>GetJumpHeight</c>.
/// </para>
/// </summary> /// </summary>
public sealed class PlayerWeenie : IWeenieObject public sealed class PlayerWeenie : IWeenieObject
{ {
/// <summary>
/// Retail <c>CACQualities::CanJump</c>'s hard burden gate (0x00591b50,
/// pc 412907) — x87 mush, polarity resolved by domain plausibility
/// (register row UN-8; Ghidra MCP was unavailable to confirm). Chosen
/// to coincide with <see cref="EncumbranceSystem.LoadMod"/>'s own floor.
/// </summary>
public const float CanJumpLoadThreshold = 2.0f;
private int _runSkill; private int _runSkill;
private int _jumpSkill; private int _jumpSkill;
private float _burden; private float _burden;
/// <summary>
/// Retail <c>AttributeCache::InqAttribute2nd(ATTR2ND_STAMINA=4)</c>'s
/// current-stamina reading, consulted by <c>InqRunRate</c>/
/// <c>InqJumpVelocity</c> to zero the effective skill when exhausted
/// (pc 413824-413898, 413902-413979). <c>null</c> = never pushed
/// (matches every pre-P1 caller/test — no gating, today's behavior
/// unchanged); any non-negative value including 0 is a real reading.
/// </summary>
private uint? _currentStamina;
public PlayerWeenie(int runSkill = 0, int jumpSkill = 0, float burden = 0f) public PlayerWeenie(int runSkill = 0, int jumpSkill = 0, float burden = 0f)
{ {
_runSkill = runSkill; _runSkill = runSkill;
@ -26,74 +64,91 @@ public sealed class PlayerWeenie : IWeenieObject
_jumpSkill = jumpSkill; _jumpSkill = jumpSkill;
} }
/// <summary>
/// Pushes the retail <c>InqLoad</c>-equivalent burden/capacity ratio
/// (0.0 unencumbered .. ~3.0 severely overloaded). Runtime computes this
/// from Strength + augmentation property 0xE6 + EncumbranceVal property
/// 5 (the same inputs <c>IndicatorBarController.UpdateBurden</c> already
/// assembles) and pushes it — see the pseudocode doc §9.
/// </summary>
public void SetBurden(float burden) => _burden = burden; public void SetBurden(float burden) => _burden = burden;
/// <summary>
/// Pushes the current-stamina reading feeding the zero-skill gate in
/// <see cref="InqRunRate"/>/<see cref="InqJumpVelocity"/>. Pass
/// <c>null</c> to return to "unknown, don't gate" (matches construction
/// default).
/// </summary>
public void SetStamina(uint? currentStamina) => _currentStamina = currentStamina;
public bool InqRunRate(out float rate) public bool InqRunRate(out float rate)
{ {
rate = GetRunRate(_burden, _runSkill); int effectiveSkill = _currentStamina == 0 ? 0 : _runSkill;
rate = MovementSystem.GetRunRate(_burden, effectiveSkill);
return true; return true;
} }
public bool InqJumpVelocity(float extent, out float vz) public bool InqJumpVelocity(float extent, out float vz)
{ {
float height = GetJumpHeight(_burden, _jumpSkill, extent); int effectiveSkill = _currentStamina == 0 ? 0 : _jumpSkill;
float height = MovementSystem.GetJumpHeight(_burden, effectiveSkill, extent);
vz = MathF.Sqrt(height * 19.6f); vz = MathF.Sqrt(height * 19.6f);
return true; return true;
} }
public bool CanJump(float extent) => true; // burden/stamina checks deferred /// <summary>
/// Retail <c>CACQualities::CanJump</c> (0x00591b50): refuses only past
/// <see cref="CanJumpLoadThreshold"/> (200% load) — see the class doc's
/// UN-8 note. TS-5 retired: this was previously an unconditional
/// <c>true</c>.
/// </summary>
public bool CanJump(float extent) => _burden < CanJumpLoadThreshold;
/// <summary> /// <summary>
/// R3-W3 (W0-pins.md A3): the local player's weenie is THE player. /// R3-W3 (W0-pins.md A3): the local player's weenie is THE player.
/// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c> /// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// dual-dispatch gate — no consumer yet in W3. /// dual-dispatch gate.
/// </summary> /// </summary>
public bool IsThePlayer() => true; public bool IsThePlayer() => true;
/// <summary> /// <summary>
/// TS-5 (extended): stamina cost gating deferred pending stat plumbing — /// Retail <c>CACQualities::JumpStaminaCost</c> (0x00591b90, pc 412949,
/// always affordable, cost 0. Matches <see cref="CanJump"/>'s existing /// FULLY READABLE): computes the real cost via
/// always-true stance. /// <see cref="MovementSystem.JumpStaminaCost"/> and returns <c>true</c>
/// unconditionally (once burden is knowable, which it always is for the
/// local player) — retail's own function never exercises the "can't
/// afford" false path; see the pseudocode doc §4/§7. TS-5 retired: this
/// was previously a zero-cost stub. <c>pk</c> is hardcoded <c>false</c>
/// pending TS-23 (PlayerKillerStatus parsing, Campaign P Slice P3).
/// </summary> /// </summary>
public bool JumpStaminaCost(float extent, out int cost) public bool JumpStaminaCost(float extent, out int cost)
{ {
cost = 0; cost = MovementSystem.JumpStaminaCost(extent, _burden, pk: false);
return true; return true;
} }
/// <summary> /// <summary>
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4 /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4.
/// Capped at 4.5 when runSkill >= 800. /// Capped at 4.5 when runSkill >= 800. Thin forwarder to
/// Source: decompiled + ACE MovementSystem.GetRunRate /// <see cref="MovementSystem.GetRunRate"/> — kept for source
/// compatibility with existing golden-value tests.
/// </summary> /// </summary>
public static float GetRunRate(float burden, int runSkill) public static float GetRunRate(float burden, int runSkill) =>
{ MovementSystem.GetRunRate(burden, runSkill);
if (runSkill >= 800) return 18f / 4f;
float loadMod = GetBurdenMod(burden);
return (loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / 4f;
}
/// <summary> /// <summary>
/// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent,
/// Clamped to minimum 0.35m. /// clamped to minimum 0.35m. Thin forwarder to
/// Source: decompiled + ACE MovementSystem.GetJumpHeight /// <see cref="MovementSystem.GetJumpHeight"/> — kept for source
/// compatibility with existing golden-value tests.
/// </summary> /// </summary>
public static float GetJumpHeight(float burden, int jumpSkill, float extent) public static float GetJumpHeight(float burden, int jumpSkill, float extent) =>
{ MovementSystem.GetJumpHeight(burden, jumpSkill, extent);
extent = Math.Clamp(extent, 0f, 1f);
float loadMod = GetBurdenMod(burden);
float height = loadMod * ((float)jumpSkill / (jumpSkill + 1300f) * 22.2f + 0.05f) * extent;
return MathF.Max(height, 0.35f);
}
/// <summary> /// <summary>
/// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at 200%. /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at
/// Source: decompiled + ACE EncumbranceSystem.GetBurdenMod /// 200%. Thin forwarder to <see cref="EncumbranceSystem.LoadMod"/> —
/// kept for source compatibility with existing golden-value tests.
/// </summary> /// </summary>
public static float GetBurdenMod(float burden) public static float GetBurdenMod(float burden) => EncumbranceSystem.LoadMod(burden);
{
if (burden < 1f) return 1f;
if (burden < 2f) return 2f - burden;
return 0f;
}
} }

View file

@ -76,11 +76,20 @@ public static class EnchantmentMath
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param> /// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
/// <param name="statKey">Target stat key (ACE /// <param name="statKey">Target stat key (ACE
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth, /// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
/// 3=MaxStamina, 5=MaxMana).</param> /// 3=MaxStamina, 5=MaxMana — or a Skill id when
/// <paramref name="requiredStatModTypeFlag"/> is
/// <see cref="EnchantmentTypeFlag.Skill"/>).</param>
/// <param name="requiredStatModTypeFlag">When set, a record's
/// <c>StatModType</c> must carry this flag bit to be considered a
/// candidate — disambiguates namespaces that share numeric keys (e.g.
/// vital key 5=MaxMana vs skill id 5). <c>null</c> (default) preserves
/// the original vitals behavior with no type check, unchanged from
/// before Campaign P.</param>
public static VitalMod GetMod( public static VitalMod GetMod(
IEnumerable<ActiveEnchantmentRecord> enchantments, IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table, SpellTable table,
uint statKey) uint statKey,
uint? requiredStatModTypeFlag = null)
{ {
// Family-stacking: bucket the active enchantments by Family and // Family-stacking: bucket the active enchantments by Family and
// keep the strongest one per bucket (the one with the largest // keep the strongest one per bucket (the one with the largest
@ -129,8 +138,20 @@ public static class EnchantmentMath
continue; continue;
} }
// Campaign P (2026-07-30): an optional type-flag gate
// disambiguates numeric-key collisions across namespaces (e.g.
// vital key 5=MaxMana vs skill id 5) — mirrors retail's
// CullEnchantmentsFromList `category` argument (2 for
// Attribute2nd, 0x10=Skill for EnchantSkill).
if (requiredStatModTypeFlag is uint typeFlag
&& (ench.StatModType is not uint recordType
|| (recordType & typeFlag) == 0))
{
continue;
}
// Multiplicative + Additive buffs filter by stat key — // Multiplicative + Additive buffs filter by stat key —
// only those targeting the requested vital contribute. // only those targeting the requested vital/skill contribute.
if (ench.StatModKey is not uint key || key != statKey) continue; if (ench.StatModKey is not uint key || key != statKey) continue;
switch (ench.Bucket) switch (ench.Bucket)
{ {
@ -147,6 +168,35 @@ public static class EnchantmentMath
: new VitalMod(multiplier, additive); : new VitalMod(multiplier, additive);
} }
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — Skill-namespace convenience over
/// <see cref="GetMod"/>, matching retail <c>CEnchantmentRegistry::
/// EnchantSkill</c> (0x005947b0): vitae applies unconditionally (same
/// as vitals), multiplicative/additive skill buffs are filtered to
/// records whose <c>StatModType</c> carries
/// <see cref="EnchantmentTypeFlag.Skill"/> AND whose <c>StatModKey</c>
/// equals <paramref name="skillId"/> (ACE Skill enum ordinal — Run=24,
/// Jump=22). Scoped to the run/jump query path per the P1 plan; not a
/// general effective-skill engine.
/// </summary>
public static VitalMod GetSkillMod(
IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table,
uint skillId) =>
GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill);
/// <summary>
/// Retail <c>EnchantmentTypeFlags</c> bits relevant to disambiguating
/// <see cref="GetMod"/>'s <c>statKey</c> namespace (ACE
/// <c>ACE.Entity.Enum.EnchantmentTypeFlags</c>, cross-referenced —
/// StatModType is a bitfield the wire already carries per-enchantment).
/// </summary>
public static class EnchantmentTypeFlag
{
public const uint SecondAtt = 0x0000002u;
public const uint Skill = 0x0000010u;
}
/// <summary> /// <summary>
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c> /// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
/// (verified against <c>docs/research/named-retail/acclient.h</c> /// (verified against <c>docs/research/named-retail/acclient.h</c>

View file

@ -24,6 +24,7 @@ public sealed class Spellbook
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new(); private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new(); private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new(); private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new();
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _skillModCache = new();
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8) private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
.Select(_ => new List<uint>()).ToArray(); .Select(_ => new List<uint>()).ToArray();
private readonly Dictionary<uint, uint> _desiredComponents = new(); private readonly Dictionary<uint, uint> _desiredComponents = new();
@ -102,6 +103,29 @@ public sealed class Spellbook
return calculated; return calculated;
} }
/// <summary>
/// Campaign P Slice P1 (2026-07-30) — combined vitae + skill-enchantment
/// buff modifier for a Skill id (ACE Skill enum ordinal — Run=24,
/// Jump=22), matching retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// (0x005947b0). Mirrors <see cref="GetVitalMod"/>'s caching shape;
/// consumed by <c>AcDream.Runtime.Gameplay.RuntimeCharacterState</c>'s
/// run/jump skill recompute.
/// </summary>
public EnchantmentMath.VitalMod GetSkillMod(uint skillId)
{
if (_skillModCache.TryGetValue(
skillId,
out EnchantmentMath.VitalMod cached))
{
return cached;
}
EnchantmentMath.VitalMod calculated =
EnchantmentMath.GetSkillMod(ActiveEnchantments, _table, skillId);
_skillModCache.Add(skillId, calculated);
return calculated;
}
/// <summary>Fires when a spell is added to the player's spellbook.</summary> /// <summary>Fires when a spell is added to the player's spellbook.</summary>
public event Action<uint>? SpellLearned; public event Action<uint>? SpellLearned;
@ -356,6 +380,7 @@ public sealed class Spellbook
_activeById.Clear(); _activeById.Clear();
_enchantmentOrderByBucket.Clear(); _enchantmentOrderByBucket.Clear();
_vitalModCache.Clear(); _vitalModCache.Clear();
_skillModCache.Clear();
foreach (ActiveEnchantmentRecord enchantment in enchantments) foreach (ActiveEnchantmentRecord enchantment in enchantments)
UpsertManifestEnchantment(enchantment); UpsertManifestEnchantment(enchantment);
@ -430,6 +455,7 @@ public sealed class Spellbook
_activeById.Clear(); _activeById.Clear();
_enchantmentOrderByBucket.Clear(); _enchantmentOrderByBucket.Clear();
_vitalModCache.Clear(); _vitalModCache.Clear();
_skillModCache.Clear();
foreach (List<uint> tab in _favoriteSpells) tab.Clear(); foreach (List<uint> tab in _favoriteSpells) tab.Clear();
_desiredComponents.Clear(); _desiredComponents.Clear();
_spellbookFilters = 0x3FFFu; _spellbookFilters = 0x3FFFu;
@ -448,6 +474,7 @@ public sealed class Spellbook
private void NotifyEnchantmentsChanged() private void NotifyEnchantmentsChanged()
{ {
_vitalModCache.Clear(); _vitalModCache.Clear();
_skillModCache.Clear();
EnchantmentsChanged?.Invoke(); EnchantmentsChanged?.Invoke();
StateChanged?.Invoke(); StateChanged?.Invoke();
} }

View file

@ -567,7 +567,13 @@ internal sealed class HeadlessSessionHost : IDisposable
OnConfirmationRequest: null, OnConfirmationRequest: null,
OnConfirmationDone: null, OnConfirmationDone: null,
ClientTime: () => ClientTime: () =>
Runtime.Clock.SimulationTimeSeconds), Runtime.Clock.SimulationTimeSeconds,
// Campaign P Slice P1 (2026-07-30): headless bots re-apply
// burden/stamina/skills at controller construction only
// (HeadlessSessionWorldProjection.CreateController), same as
// the pre-P1 OnSkillsUpdated: null pattern — no live
// controller to reactively re-apply to mid-session here.
OnMovementStatsUpdated: null),
new LiveSocialSessionBindings( new LiveSocialSessionBindings(
Runtime.CommunicationOwner.Chat, Runtime.CommunicationOwner.Chat,
Runtime.CommunicationOwner.TurbineChat, Runtime.CommunicationOwner.TurbineChat,

View file

@ -891,6 +891,30 @@ public sealed class PlayerMovementController
_weenie.SetSkills(runSkill, jumpSkill); _weenie.SetSkills(runSkill, jumpSkill);
} }
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes the retail
/// <c>InqLoad</c>-equivalent burden ratio computed by Runtime (Strength
/// + augmentation property 0xE6 + EncumbranceVal property 5) into the
/// player's <see cref="PlayerWeenie"/> — wires the previously-dead
/// <c>PlayerWeenie.SetBurden</c> setter (TS-5 retired).
/// </summary>
public void SetCharacterBurden(float burden)
{
_weenie.SetBurden(burden);
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes the current-stamina vital
/// reading. A negative value restores PlayerWeenie's "unknown, don't
/// gate" sentinel; any non-negative value including 0 gates
/// <c>InqRunRate</c>/<c>InqJumpVelocity</c>'s effective-skill-zeroing per
/// retail's <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>.
/// </summary>
public void SetCharacterStamina(int currentStamina)
{
_weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina);
}
/// <summary> /// <summary>
/// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/> /// R3-W2 (r3-port-plan.md §4): the player's <see cref="MotionInterpreter"/>
/// — GameWindow binds the player sequencer's MotionDone seam to it so the /// — GameWindow binds the player sequencer's MotionDone seam to it so the

View file

@ -43,11 +43,28 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot(
/// </summary> /// </summary>
public sealed class RuntimeCharacterState : IDisposable public sealed class RuntimeCharacterState : IDisposable
{ {
/// <summary>ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5).</summary>
public const uint RunSkillId = 24u;
/// <summary>ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5).</summary>
public const uint JumpSkillId = 22u;
private bool _disposed; private bool _disposed;
private long _characterRevision; private long _characterRevision;
private long _spellbookRevision; private long _spellbookRevision;
private bool _internalSubscriptionsAttached; private bool _internalSubscriptionsAttached;
/// <summary>
/// Campaign P Slice P1 (2026-07-30): the pre-<c>EnchantSkill</c> base
/// run/jump skill (formulaBonus+init+ranks, as parsed from
/// PlayerDescription) — kept so <see cref="RecomputeMovementSkills"/>
/// can re-derive the vitae/enchantment-adjusted value purely from a
/// spellbook change, without waiting for a fresh skill push. -1 =
/// unknown (mirrors <see cref="RuntimeMovementSkillState"/>'s own
/// sentinel convention).
/// </summary>
private int _runSkillBase = -1;
private int _jumpSkillBase = -1;
public RuntimeCharacterState(SpellTable? spellTable = null) public RuntimeCharacterState(SpellTable? spellTable = null)
{ {
Spellbook = new Spellbook(spellTable); Spellbook = new Spellbook(spellTable);
@ -56,6 +73,7 @@ public sealed class RuntimeCharacterState : IDisposable
MovementSkills = new RuntimeMovementSkillState(); MovementSkills = new RuntimeMovementSkillState();
View = new CharacterView(this); View = new CharacterView(this);
Spellbook.StateChanged += OnSpellbookChanged; Spellbook.StateChanged += OnSpellbookChanged;
Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement;
LocalPlayer.Changed += OnVitalChanged; LocalPlayer.Changed += OnVitalChanged;
LocalPlayer.AttributeChanged += OnAttributeChanged; LocalPlayer.AttributeChanged += OnAttributeChanged;
LocalPlayer.CharacterChanged += OnCharacterChanged; LocalPlayer.CharacterChanged += OnCharacterChanged;
@ -117,9 +135,66 @@ public sealed class RuntimeCharacterState : IDisposable
&& options.Options2 && options.Options2
== RuntimeCharacterOptionsState.DefaultOptions2, == RuntimeCharacterOptionsState.DefaultOptions2,
MovementSkills.RunSkill == -1 MovementSkills.RunSkill == -1
&& MovementSkills.JumpSkill == -1); && MovementSkills.JumpSkill == -1
&& MovementSkills.Burden == 0f
&& MovementSkills.CurrentStamina == -1
&& _runSkillBase == -1
&& _jumpSkillBase == -1);
} }
/// <summary>
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
/// and pushes the vitae/enchantment-adjusted result into
/// <see cref="MovementSkills"/> — the SAME call shape
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
/// routed through the retail <c>CEnchantmentRegistry::EnchantSkill</c>
/// chain. A value &lt; 0 leaves that half's base untouched (matches
/// <see cref="RuntimeMovementSkillState.Update"/>'s own "don't touch"
/// convention for a missing half).
/// </summary>
public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (runSkillBase >= 0) _runSkillBase = runSkillBase;
if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase;
RecomputeMovementSkills();
}
/// <summary>
/// Re-derives the adjusted run/jump skill from the stored base plus the
/// CURRENT spellbook state (vitae + skill enchantments) — matching
/// retail's <c>CEnchantmentRegistry::EnchantSkill</c> (0x005947b0):
/// vitae multiplies first, then matching Skill-flagged mult/add
/// enchantments, floored to 0 below 0.5, truncated to int. Fires on
/// every base push AND on every <see cref="Spellbook.EnchantmentsChanged"/>
/// notification (a vitae/buff change alone must move the produced rate
/// without a fresh PlayerDescription).
/// </summary>
private void RecomputeMovementSkills()
{
int run = _runSkillBase >= 0
? ApplySkillEnchantments(_runSkillBase, RunSkillId)
: -1;
int jump = _jumpSkillBase >= 0
? ApplySkillEnchantments(_jumpSkillBase, JumpSkillId)
: -1;
MovementSkills.Update(run, jump);
}
private int ApplySkillEnchantments(int baseSkill, uint skillId)
{
EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId);
float adjusted = baseSkill * mod.Multiplier + mod.Additive;
// CEnchantmentRegistry::EnchantSkill pc 416240: floor to 0 below
// 0.5, then truncate (retail _ftol2, a C-style cast).
if (adjusted < 0.5f) adjusted = 0f;
return (int)adjusted;
}
private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills();
/// <summary> /// <summary>
/// Installs immutable DAT metadata without transferring its ownership to /// Installs immutable DAT metadata without transferring its ownership to
/// Runtime. The content host may install one table after portal.dat opens. /// Runtime. The content host may install one table after portal.dat opens.
@ -241,6 +316,8 @@ public sealed class RuntimeCharacterState : IDisposable
Try(Spellbook.Clear, ref failures); Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures); Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures); Try(Options.ResetSession, ref failures);
_runSkillBase = -1;
_jumpSkillBase = -1;
Try(MovementSkills.ResetSession, ref failures); Try(MovementSkills.ResetSession, ref failures);
if (failures is not null) if (failures is not null)
{ {
@ -263,11 +340,14 @@ public sealed class RuntimeCharacterState : IDisposable
Try(Spellbook.Clear, ref failures); Try(Spellbook.Clear, ref failures);
Try(LocalPlayer.Clear, ref failures); Try(LocalPlayer.Clear, ref failures);
Try(Options.ResetSession, ref failures); Try(Options.ResetSession, ref failures);
_runSkillBase = -1;
_jumpSkillBase = -1;
Try(MovementSkills.ResetSession, ref failures); Try(MovementSkills.ResetSession, ref failures);
} }
finally finally
{ {
Spellbook.StateChanged -= OnSpellbookChanged; Spellbook.StateChanged -= OnSpellbookChanged;
Spellbook.EnchantmentsChanged -= OnEnchantmentsChangedForMovement;
LocalPlayer.Changed -= OnVitalChanged; LocalPlayer.Changed -= OnVitalChanged;
LocalPlayer.AttributeChanged -= OnAttributeChanged; LocalPlayer.AttributeChanged -= OnAttributeChanged;
LocalPlayer.CharacterChanged -= OnCharacterChanged; LocalPlayer.CharacterChanged -= OnCharacterChanged;
@ -467,28 +547,42 @@ public sealed class RuntimeCharacterOptionsState
public readonly record struct RuntimeMovementSkillSnapshot( public readonly record struct RuntimeMovementSkillSnapshot(
int RunSkill, int RunSkill,
int JumpSkill, int JumpSkill,
long Revision) long Revision,
// Campaign P Slice P1 (2026-07-30): retail InqLoad-equivalent burden
// ratio (0.0 unencumbered) and current stamina (-1 = unknown/don't-gate,
// matching RunSkill/JumpSkill's own sentinel convention).
float Burden = 0f,
int CurrentStamina = -1)
{ {
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0; public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
} }
/// <summary> /// <summary>
/// Server-authoritative run/jump values retained independently of any /// Server-authoritative run/jump/burden/stamina values retained
/// graphical movement controller. App applies this borrowed state whenever /// independently of any graphical movement controller. App applies this
/// its presentation/physics controller exists or is rebuilt. /// borrowed state whenever its presentation/physics controller exists or is
/// rebuilt. Campaign P Slice P1 (2026-07-30) extended this beyond run/jump
/// skill to the full stat-coupled-movement input set (burden, current
/// stamina) — see the pseudocode doc §9. RunSkill/JumpSkill arrive here
/// ALREADY vitae/enchantment-adjusted by
/// <see cref="RuntimeCharacterState.RecomputeMovementSkills"/>.
/// </summary> /// </summary>
public sealed class RuntimeMovementSkillState public sealed class RuntimeMovementSkillState
{ {
private int _runSkill = -1; private int _runSkill = -1;
private int _jumpSkill = -1; private int _jumpSkill = -1;
private float _burden;
private int _currentStamina = -1;
private long _revision; private long _revision;
public int RunSkill => Volatile.Read(ref _runSkill); public int RunSkill => Volatile.Read(ref _runSkill);
public int JumpSkill => Volatile.Read(ref _jumpSkill); public int JumpSkill => Volatile.Read(ref _jumpSkill);
public float Burden => Volatile.Read(ref _burden);
public int CurrentStamina => Volatile.Read(ref _currentStamina);
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0; public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
public long Revision => Interlocked.Read(ref _revision); public long Revision => Interlocked.Read(ref _revision);
public RuntimeMovementSkillSnapshot Snapshot => public RuntimeMovementSkillSnapshot Snapshot =>
new(_runSkill, _jumpSkill, Revision); new(_runSkill, _jumpSkill, Revision, _burden, _currentStamina);
public void Update(int runSkill, int jumpSkill) public void Update(int runSkill, int jumpSkill)
{ {
@ -507,10 +601,37 @@ public sealed class RuntimeMovementSkillState
Interlocked.Increment(ref _revision); Interlocked.Increment(ref _revision);
} }
/// <summary>
/// Pushes a fresh retail <c>InqLoad</c>-equivalent burden ratio (Strength
/// + augmentation property 0xE6 + EncumbranceVal property 5 — the same
/// inputs the burden HUD already assembles). Any value, including 0,
/// is a real reading.
/// </summary>
public void UpdateBurden(float burden)
{
if (Burden == burden) return;
Volatile.Write(ref _burden, burden);
Interlocked.Increment(ref _revision);
}
/// <summary>
/// Pushes the current-stamina vital reading. Any non-negative value,
/// including 0 (exhausted — zeroes the effective run/jump skill), is
/// real; pass a negative value only to restore the "unknown" sentinel.
/// </summary>
public void UpdateStamina(int currentStamina)
{
if (CurrentStamina == currentStamina) return;
Volatile.Write(ref _currentStamina, currentStamina);
Interlocked.Increment(ref _revision);
}
public void ResetSession() public void ResetSession()
{ {
Volatile.Write(ref _runSkill, -1); Volatile.Write(ref _runSkill, -1);
Volatile.Write(ref _jumpSkill, -1); Volatile.Write(ref _jumpSkill, -1);
Volatile.Write(ref _burden, 0f);
Volatile.Write(ref _currentStamina, -1);
Interlocked.Increment(ref _revision); Interlocked.Increment(ref _revision);
} }
} }

View file

@ -19,6 +19,10 @@ public static class RuntimeMovementSkillProjection
controller.SetCharacterSkills( controller.SetCharacterSkills(
snapshot.RunSkill, snapshot.RunSkill,
snapshot.JumpSkill); snapshot.JumpSkill);
// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME
// seam run/jump skill already used — see the pseudocode doc §9.
controller.SetCharacterBurden(snapshot.Burden);
controller.SetCharacterStamina(snapshot.CurrentStamina);
return true; return true;
} }
} }

View file

@ -3,7 +3,9 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -44,7 +46,14 @@ public sealed record LiveCharacterSessionBindings(
Action<int, int>? OnSkillsUpdated, Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest, Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone, Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Func<double>? ClientTime); Func<double>? ClientTime,
// Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden,
// stamina, OR (vitae/enchantment-adjusted) skill values change mid-
// session — the reactive re-apply-to-the-live-controller seam, mirroring
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
// unchanged.
Action? OnMovementStatsUpdated = null);
public sealed record LiveSocialSessionBindings( public sealed record LiveSocialSessionBindings(
ChatLog Chat, ChatLog Chat,
@ -153,10 +162,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
social.TurbineChat, social.TurbineChat,
onSkillsUpdated: (runSkill, jumpSkill) => onSkillsUpdated: (runSkill, jumpSkill) =>
{ {
character.Character.MovementSkills.Update( // Campaign P Slice P1 (2026-07-30): route the PD/skill
// base through the vitae/enchantment-adjusted recompute
// (CEnchantmentRegistry::EnchantSkill) instead of writing
// MovementSkills directly — see the pseudocode doc §9.
character.Character.UpdateMovementSkillBase(
runSkill, runSkill,
jumpSkill); jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill); character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
character.OnMovementStatsUpdated?.Invoke();
}, },
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts, onShortcuts: inventory.OnShortcuts,
@ -174,6 +188,52 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
externalContainers: inventory.ExternalContainers, externalContainers: inventory.ExternalContainers,
accepting: IsAccepting)); accepting: IsAccepting));
ConstructionCheckpoint(); ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
// the SAME event set IndicatorBarController.UpdateBurden already
// reacts to (Strength + augmentation property 0xE6 +
// EncumbranceVal property 5, falling back to SumCarriedBurden).
// See the pseudocode doc §9.
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectAdded += h,
h => inventory.Objects.ObjectAdded -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectUpdated += h,
h => inventory.Objects.ObjectUpdated -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectRemoved += h,
h => inventory.Objects.ObjectRemoved -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObjectMove>(
h => inventory.Objects.ObjectMoved += h,
h => inventory.Objects.ObjectMoved -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<uint>(
h => inventory.Objects.ContainerContentsReplaced += h,
h => inventory.Objects.ContainerContentsReplaced -= h,
() => RecomputeBurden(inventory, character));
SubscribeParameterless(
h => inventory.Objects.Cleared += h,
h => inventory.Objects.Cleared -= h,
() => RecomputeBurden(inventory, character));
Subscribe<LocalPlayerState.AttributeKind>(
h => character.Character.LocalPlayer.AttributeChanged += h,
h => character.Character.LocalPlayer.AttributeChanged -= h,
kind =>
{
if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character);
});
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
Subscribe<LocalPlayerState.VitalKind>(
h => character.Character.LocalPlayer.Changed += h,
h => character.Character.LocalPlayer.Changed -= h,
kind => RecomputeStamina(kind, character));
_subscriptions.Add(new CombatChatTranslator( _subscriptions.Add(new CombatChatTranslator(
character.Combat, character.Combat,
social.Chat, social.Chat,
@ -259,6 +319,89 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ConstructionCheckpoint(); ConstructionCheckpoint();
} }
/// <summary>
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
/// job is "something relevant changed, recompute" — thin wrapper over
/// <see cref="Subscribe{T}"/> that discards the payload.
/// </summary>
private void SubscribeToRecompute<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action recompute) =>
Subscribe(attach, detach, (T _) => recompute());
/// <summary>
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
/// no payload).
/// </summary>
private void SubscribeParameterless(
Action<Action> attach,
Action<Action> detach,
Action sink)
{
Action handler = () =>
{
if (Volatile.Read(ref _accepting) != 0)
sink();
};
attach(handler);
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
/// property 5, falling back to the summed carried burden) — the SAME
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
/// <c>InventoryController.RefreshBurden</c> already use for the burden
/// HUD. See the pseudocode doc §2/§9.
/// </summary>
private static void RecomputeBurden(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
int strength = (int)(character.Character.LocalPlayer
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u);
int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);
int burden = playerObject is not null
&& playerObject.Properties.Ints.TryGetValue(
(uint)PropertyInt.EncumbranceVal, out int wireBurden)
? wireBurden
: inventory.Objects.SumCarriedBurden(player);
float load = EncumbranceSystem.Load(capacity, burden);
character.Character.MovementSkills.UpdateBurden(load);
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
/// effective-skill-zeroing gate (pseudocode doc §5).
/// </summary>
private static void RecomputeStamina(
LocalPlayerState.VitalKind kind,
LiveCharacterSessionBindings character)
{
if (kind != LocalPlayerState.VitalKind.Stamina) return;
if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina)
is not LocalPlayerState.VitalSnapshot stamina)
{
return;
}
character.Character.MovementSkills.UpdateStamina((int)stamina.Current);
character.OnMovementStatsUpdated?.Invoke();
}
private void ConstructionCheckpoint() => private void ConstructionCheckpoint() =>
_constructionCheckpoint?.Invoke(++_constructionStep); _constructionCheckpoint?.Invoke(++_constructionStep);

View file

@ -0,0 +1,59 @@
using AcDream.Core.Items;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Retail <c>EncumbranceSystem</c> (0x004fcc00/40/70, Campaign P Slice P1) —
/// cross-checked 1:1 against the existing <see cref="BurdenMath"/>
/// implementation it delegates to (same formulas, same addresses).
/// </summary>
public class EncumbranceSystemTests
{
[Theory]
[InlineData(0, 0, 0)]
[InlineData(-5, 0, 0)]
[InlineData(100, 0, 15000)] // 150 * 100
[InlineData(100, 3, 24000)] // 150*100 + clamp(3*30,0,150)*100 = 15000 + 9000
[InlineData(100, 100, 30000)] // aug clamps at 150 bonus: 15000 + 150*100
public void EncumbranceCapacity_MatchesBurdenMath(int strength, int aug, int expected)
{
Assert.Equal(expected, EncumbranceSystem.EncumbranceCapacity(strength, aug));
Assert.Equal(
BurdenMath.EncumbranceCapacity(strength, aug),
EncumbranceSystem.EncumbranceCapacity(strength, aug));
}
[Theory]
[InlineData(0, 0, 0f)]
[InlineData(1000, 500, 0.5f)]
[InlineData(1000, 1000, 1.0f)]
[InlineData(1000, 2000, 2.0f)]
public void Load_MatchesBurdenMath(int capacity, int burden, float expected)
{
Assert.Equal(expected, EncumbranceSystem.Load(capacity, burden), precision: 4);
Assert.Equal(
BurdenMath.LoadRatio(capacity, burden),
EncumbranceSystem.Load(capacity, burden),
precision: 5);
}
[Theory]
[InlineData(0f, 1f)]
[InlineData(0.99f, 1f)]
[InlineData(1.0f, 1f)]
[InlineData(1.25f, 0.75f)]
[InlineData(1.5f, 0.5f)]
[InlineData(1.75f, 0.25f)]
[InlineData(2.0f, 0f)]
[InlineData(3.0f, 0f)]
public void LoadMod_KneesAt100And200Percent(float load, float expected)
{
Assert.Equal(expected, EncumbranceSystem.LoadMod(load), precision: 4);
Assert.Equal(
BurdenMath.LoadModifier(load),
EncumbranceSystem.LoadMod(load),
precision: 5);
}
}

View file

@ -0,0 +1,129 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Golden-value tables for retail <c>MovementSystem</c>
/// (docs/research/2026-07-30-stat-coupled-movement-pseudocode.md §6),
/// Campaign P Slice P1.
/// </summary>
public class MovementSystemTests
{
[Fact]
public void GetRunRate_Skill800Cap_Returns4Point5()
{
Assert.Equal(4.5f, MovementSystem.GetRunRate(0f, 800), precision: 5);
Assert.Equal(4.5f, MovementSystem.GetRunRate(0f, 999999), precision: 5);
}
[Fact]
public void GetRunRate_Skill200_MatchesFormula()
{
// (1.0 * (200/400 * 11) + 4) / 4 = (5.5 + 4) / 4 = 2.375
Assert.Equal(2.375f, MovementSystem.GetRunRate(0f, 200), precision: 3);
}
[Fact]
public void GetRunRate_Skill0_ReturnsBase()
{
Assert.Equal(1.0f, MovementSystem.GetRunRate(0f, 0), precision: 3);
}
[Theory]
[InlineData(0f, 1f)] // unencumbered
[InlineData(0.99f, 1f)] // just under 100%
[InlineData(1.0f, 1f)] // exactly 100% — still full effectiveness
[InlineData(1.5f, 0.5f)] // knee: halfway to 200%
[InlineData(2.0f, 0f)] // knee floor
[InlineData(3.0f, 0f)] // fully overloaded
public void GetRunRate_LoadKnees_ScaleLinearly(float burden, float expectedLoadMod)
{
// At runSkill=200: rate = (loadMod * 5.5 + 4) / 4.
float expected = (expectedLoadMod * 5.5f + 4f) / 4f;
Assert.Equal(expected, MovementSystem.GetRunRate(burden, 200), precision: 3);
}
[Fact]
public void GetJumpHeight_FullExtent_Skill100_MatchesFormula()
{
// height = 1.0 * (100/1400 * 22.2 + 0.05) * 1.0 = 1.636...
float expected = (100f / 1400f * 22.2f + 0.05f);
Assert.Equal(expected, MovementSystem.GetJumpHeight(0f, 100, 1.0f), precision: 3);
}
[Theory]
[InlineData(0f)]
[InlineData(0.5f)]
[InlineData(1.0f)]
public void GetJumpHeight_ExtentScalesLinearly(float extent)
{
float unscaled = 100f / 1400f * 22.2f + 0.05f;
float expected = System.Math.Max(unscaled * extent, 0.35f);
Assert.Equal(expected, MovementSystem.GetJumpHeight(0f, 100, extent), precision: 3);
}
[Fact]
public void GetJumpHeight_ClampsExtentAbove1()
{
Assert.Equal(
MovementSystem.GetJumpHeight(0f, 100, 1.0f),
MovementSystem.GetJumpHeight(0f, 100, 5.0f),
precision: 5);
}
[Fact]
public void GetJumpHeight_ClampsExtentBelow0()
{
Assert.Equal(0.35f, MovementSystem.GetJumpHeight(0f, 100, -3.0f), precision: 5);
}
[Fact]
public void GetJumpHeight_ZeroSkill_FloorsAt0Point35()
{
Assert.Equal(0.35f, MovementSystem.GetJumpHeight(0f, 0, 1.0f), precision: 5);
}
[Theory]
[InlineData(0f)]
[InlineData(1.0f)]
[InlineData(1.5f)]
[InlineData(2.0f)]
[InlineData(3.0f)]
public void GetJumpHeight_AtHeavyLoad_NeverGoesBelowFloor(float burden)
{
Assert.True(MovementSystem.GetJumpHeight(burden, 100, 1.0f) >= 0.35f);
}
[Theory]
// ceil((load + 0.5) * power * 8 + 2) — verbatim decomp, pc 696014-696024.
[InlineData(0f, 0f, 2)] // ceil((0+0.5)*0*8+2) = ceil(2) = 2
[InlineData(0f, 1f, 6)] // ceil((0+0.5)*1*8+2) = ceil(4+2) = 6
[InlineData(1f, 1f, 14)] // ceil((1+0.5)*1*8+2) = ceil(12+2) = 14
[InlineData(2f, 1f, 22)] // ceil((2+0.5)*1*8+2) = ceil(20+2) = 22
[InlineData(0.3f, 0.7f, 7)] // ceil((0.3+0.5)*0.7*8+2) = ceil(4.48+2) = ceil(6.48) = 7
public void JumpStaminaCost_NonPk_CeilsCorrectly(float burden, float power, int expected)
{
Assert.Equal(expected, MovementSystem.JumpStaminaCost(power, burden, pk: false));
}
[Fact]
public void JumpStaminaCost_Pk_UsesAceTiebreakerFormula()
{
// ACE tiebreaker (pk!=0 branch entirely dropped by BN): (power+1.0)*100.0
Assert.Equal(150, MovementSystem.JumpStaminaCost(0.5f, burden: 1f, pk: true));
Assert.Equal(100, MovementSystem.JumpStaminaCost(0f, burden: 1f, pk: true));
}
[Fact]
public void GetJumpPower_IsAlgebraicInverseOfJumpStaminaCost()
{
// Not consumed by P1 (ported for signature completeness); sanity-check
// the inverse relationship still holds for the non-pk formula shape.
float burden = 1f;
uint stamina = 20u;
float power = MovementSystem.GetJumpPower(stamina, burden, pk: false);
// (stamina - 2) / (burden*8 + 4) = 18 / 12 = 1.5
Assert.Equal(1.5f, power, precision: 3);
}
}

View file

@ -81,4 +81,114 @@ public class PlayerWeenieTests
Assert.Equal(0.5f, PlayerWeenie.GetBurdenMod(1.5f), precision: 3); Assert.Equal(0.5f, PlayerWeenie.GetBurdenMod(1.5f), precision: 3);
Assert.Equal(0.75f, PlayerWeenie.GetBurdenMod(1.25f), precision: 3); Assert.Equal(0.75f, PlayerWeenie.GetBurdenMod(1.25f), precision: 3);
} }
// ── Campaign P Slice P1 (2026-07-30): CanJump / JumpStaminaCost / ─────
// ── stamina-zeroing gate ────────────────────────────────────────────
[Fact]
public void CanJump_DefaultUnencumbered_ReturnsTrue()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
Assert.True(pw.CanJump(1.0f));
}
[Theory]
[InlineData(0f)]
[InlineData(1.0f)]
[InlineData(1.99f)]
public void CanJump_BelowThreshold_ReturnsTrue(float burden)
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: burden);
Assert.True(pw.CanJump(1.0f));
}
[Theory]
[InlineData(2.0f)]
[InlineData(2.5f)]
[InlineData(3.0f)]
public void CanJump_AtOrAboveThreshold_ReturnsFalse(float burden)
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: burden);
Assert.False(pw.CanJump(1.0f));
}
[Fact]
public void CanJump_SetBurden_UpdatesGateLive()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
Assert.True(pw.CanJump(1.0f));
pw.SetBurden(2.5f);
Assert.False(pw.CanJump(1.0f));
pw.SetBurden(0.5f);
Assert.True(pw.CanJump(1.0f));
}
[Fact]
public void JumpStaminaCost_ReturnsRealNonzeroCost_AndAlwaysAffordable()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
Assert.True(pw.JumpStaminaCost(1.0f, out int cost));
// burden=0: ceil((0+0.5)*1*8+2) = 6 — no longer the pre-P1 zero stub.
Assert.Equal(6, cost);
}
[Fact]
public void JumpStaminaCost_ScalesWithBurden()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: 1.0f);
Assert.True(pw.JumpStaminaCost(1.0f, out int cost));
// ceil((1+0.5)*1*8+2) = 14
Assert.Equal(14, cost);
}
[Fact]
public void InqRunRate_ZeroStamina_ZeroesEffectiveSkill()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
pw.SetStamina(0);
Assert.True(pw.InqRunRate(out float rate));
// skill forced to 0 -> base rate 1.0 (matches InqRunRate_Skill0_ReturnsBase).
Assert.Equal(1.0f, rate, precision: 3);
}
[Fact]
public void InqRunRate_NonzeroStamina_UsesRealSkill()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
pw.SetStamina(50);
Assert.True(pw.InqRunRate(out float rate));
Assert.Equal(2.375f, rate, precision: 3);
}
[Fact]
public void InqRunRate_UnsetStamina_NeverGates()
{
// No SetStamina call -> null sentinel -> matches every pre-P1 test's
// implicit expectation (no gating at all).
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
Assert.True(pw.InqRunRate(out float rate));
Assert.Equal(2.375f, rate, precision: 3);
}
[Fact]
public void InqJumpVelocity_ZeroStamina_FloorsAt0Point35Meters()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
pw.SetStamina(0);
Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
Assert.Equal(MathF.Sqrt(0.35f * 19.6f), vz, precision: 2);
}
[Fact]
public void SetStamina_Null_RestoresUnknownSentinel()
{
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
pw.SetStamina(0);
Assert.True(pw.InqRunRate(out float zeroedRate));
Assert.Equal(1.0f, zeroedRate, precision: 3);
pw.SetStamina(null);
Assert.True(pw.InqRunRate(out float restoredRate));
Assert.Equal(2.375f, restoredRate, precision: 3);
}
} }

View file

@ -228,6 +228,108 @@ public sealed class EnchantmentMathTests
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) => private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 4u); new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 4u);
// ── Campaign P Slice P1 (2026-07-30): GetSkillMod (run/jump skill ─────
// ── vitae/enchantment adjustment) ──────────────────────────────────
private static ActiveEnchantmentRecord MakeSkillMultRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 1u);
private static ActiveEnchantmentRecord MakeSkillAddRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 2u);
[Fact]
public void EnchantmentTypeFlag_Skill_MatchesAceEnchantmentTypeFlags()
{
// ACE.Entity.Enum.EnchantmentTypeFlags.Skill = 0x0000010.
Assert.Equal(0x0000010u, EnchantmentMath.EnchantmentTypeFlag.Skill);
Assert.Equal(0x0000002u, EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
}
[Fact]
public void GetSkillMod_Empty_ReturnsIdentity()
{
var mod = EnchantmentMath.GetSkillMod(
Array.Empty<ActiveEnchantmentRecord>(), SpellTable.Empty, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_MultiplicativeSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((50u, "Run Buff", 400u));
var enchantments = new[] { MakeSkillMultRecord(50, 1, skillId: 24u, val: 1.2f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(1.2f, mod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_AdditiveSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((51u, "Jump Buff", 401u));
var enchantments = new[] { MakeSkillAddRecord(51, 1, skillId: 22u, val: 15f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 22u);
Assert.Equal(15.0f, mod.Additive, precision: 4);
}
[Fact]
public void GetSkillMod_SkillIdMismatch_DoesNotContribute()
{
var table = LoadTable((52u, "Melee Buff", 402u));
// Buff targets skill id 7 (some melee skill), we ask for Run (24).
var enchantments = new[] { MakeSkillMultRecord(52, 1, skillId: 7u, val: 1.5f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_NamespaceCollision_VitalTypedRecordDoesNotLeakIntoSkillQuery()
{
// A vital-typed (SecondAtt) buff whose numeric StatModKey happens to
// equal a skill id (24=Run) must NOT contribute to a skill query —
// the whole point of the type-flag filter (pseudocode doc §9).
var table = LoadTable((53u, "Vital Buff", 403u));
var enchantments = new[]
{
new ActiveEnchantmentRecord(
53u, 1u, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: 24u,
StatModValue: 1.5f,
Bucket: 1u),
};
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
// The SAME record DOES contribute to a vitals query with no type
// filter (GetMod's pre-P1, unmodified default behavior).
var vitalMod = EnchantmentMath.GetMod(enchantments, table, statKey: 24u);
Assert.Equal(1.5f, vitalMod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_Vitae_AppliesUnconditionallyLikeVitals()
{
// CEnchantmentRegistry::EnchantSkill applies _vitae in the IDENTICAL
// position as EnchantAttribute2nd (pseudocode doc §5) — reusing the
// SAME Bucket==4 handling, unconditional on the type-flag filter.
var table = LoadTable((54u, "Vitae", 0u));
var enchantments = new[] { MakeVitaeRecord(54, 0, statKey: 0u, val: 0.9f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(0.9f, mod.Multiplier, precision: 3);
}
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows) private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
{ {
// Build a synthetic CSV with just enough columns for SpellTable to // Build a synthetic CSV with just enough columns for SpellTable to

View file

@ -826,4 +826,39 @@ public class PlayerMovementControllerTests
Assert.False(controller.IsAirborne, "Player should have landed"); Assert.False(controller.IsAirborne, "Player should have landed");
Assert.Equal(20f, controller.Position.Z, precision: 1); Assert.Equal(20f, controller.Position.Z, precision: 1);
} }
// ── Campaign P Slice P1 (2026-07-30): burden/stamina push ──────────────
[Fact]
public void SetCharacterBurden_PropagatesToTheWeenieAndGatesCanJump()
{
var controller = new PlayerMovementController(MakeFlatEngine());
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.CanJump(1.0f));
controller.SetCharacterBurden(2.5f);
Assert.False(weenie.CanJump(1.0f));
controller.SetCharacterBurden(0.5f);
Assert.True(weenie.CanJump(1.0f));
}
[Fact]
public void SetCharacterStamina_ZeroesEffectiveSkillOnTheWeenie()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetCharacterSkills(runSkill: 200, jumpSkill: 100);
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float baseline));
controller.SetCharacterStamina(0);
Assert.True(weenie.InqRunRate(out float exhausted));
Assert.True(exhausted < baseline);
controller.SetCharacterStamina(-1);
Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4);
}
} }

View file

@ -193,6 +193,127 @@ public sealed class RuntimeCharacterStateTests
Assert.Equal(-1, state.MovementSkills.JumpSkill); Assert.Equal(-1, state.MovementSkills.JumpSkill);
} }
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
[Fact]
public void UpdateMovementSkillBase_NoEnchantments_PushesBaseUnchanged()
{
using var state = new RuntimeCharacterState();
state.UpdateMovementSkillBase(runSkillBase: 210, jumpSkillBase: 165);
Assert.Equal(210, state.MovementSkills.RunSkill);
Assert.Equal(165, state.MovementSkills.JumpSkill);
}
[Fact]
public void UpdateMovementSkillBase_VitaeActive_AppliesMultiplierToPushedSkill()
{
SpellTable table = SpellTableWith((1u, "Vitae", 0u));
using var state = new RuntimeCharacterState(table);
state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.9f));
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
// CEnchantmentRegistry::EnchantSkill applies vitae first: 200*0.9=180.
Assert.Equal(180, state.MovementSkills.RunSkill);
Assert.Equal(90, state.MovementSkills.JumpSkill);
}
[Fact]
public void EnchantmentsChanged_AfterBaseAlreadyPushed_RecomputesWithoutFreshBase()
{
SpellTable table = SpellTableWith((1u, "Vitae", 0u));
using var state = new RuntimeCharacterState(table);
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
Assert.Equal(200, state.MovementSkills.RunSkill);
// A vitae buff lands mid-session — WITHOUT a fresh PD skill push —
// and the produced run skill must still move (pseudocode doc §9's
// "Spellbook.EnchantmentsChanged -> RecomputeMovementSkills" wire).
state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.95f));
Assert.Equal(190, state.MovementSkills.RunSkill);
}
[Fact]
public void EnchantmentsChanged_SkillSpecificBuff_AppliesToMatchingSkillOnly()
{
SpellTable table = SpellTableWith((77u, "Run Buff", 0u));
using var state = new RuntimeCharacterState(table);
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 77u,
LayerId: 1u,
Duration: 60f,
CasterGuid: 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: RuntimeCharacterState.RunSkillId,
StatModValue: 1.5f,
Bucket: 1u));
Assert.Equal(300, state.MovementSkills.RunSkill); // 200 * 1.5
Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched
}
[Fact]
public void ResetSession_ClearsBurdenStaminaAndSkillBase()
{
using var state = new RuntimeCharacterState();
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.MovementSkills.UpdateBurden(1.5f);
state.MovementSkills.UpdateStamina(0);
state.ResetSession();
Assert.Equal(-1, state.MovementSkills.RunSkill);
Assert.Equal(-1, state.MovementSkills.JumpSkill);
Assert.Equal(0f, state.MovementSkills.Burden);
Assert.Equal(-1, state.MovementSkills.CurrentStamina);
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
// A fresh base push after reset must not still carry the pre-reset
// vitae/enchantment adjustment (spellbook was cleared too).
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
Assert.Equal(200, state.MovementSkills.RunSkill);
}
[Fact]
public void CaptureOwnership_BurdenOrStaminaLeftoverBreaksMovementSkillsReset()
{
using var state = new RuntimeCharacterState();
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateBurden(0.5f);
Assert.False(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateBurden(0f);
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateStamina(80);
Assert.False(state.CaptureOwnership().MovementSkillsAreReset);
}
private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) =>
new(
spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: val, Bucket: 4u);
private static SpellTable SpellTableWith(
params (uint id, string name, uint family)[] rows)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10");
foreach ((uint id, string name, uint family) in rows)
{
sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',')
.Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0")
.AppendLine();
}
return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString()));
}
[Fact] [Fact]
public void CharacterViewBorrowsExactOwnersWithoutReconstructedState() public void CharacterViewBorrowsExactOwnersWithoutReconstructedState()
{ {

View file

@ -5,6 +5,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Player; using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social; using AcDream.Core.Social;
using AcDream.Core.Spells; using AcDream.Core.Spells;
using AcDream.Runtime.Session; using AcDream.Runtime.Session;
@ -180,6 +181,111 @@ public sealed class LiveSessionEventRouterTests
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
} }
// ── Campaign P Slice P1 (2026-07-30): burden/stamina recompute wiring ─
[Fact]
public void ObjectTablePropertyChange_RecomputesAndPushesBurden()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
int movementStatsUpdated = 0;
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
objects,
PlayerGuid: () => playerGuid,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d,
OnMovementStatsUpdated: () => movementStatsUpdated++),
NewSocialBindings());
router.Attach();
// Strength=100, no augmentation -> capacity=15000 (BurdenMath).
character.LocalPlayer.OnAttributeUpdate(
atType: 1u, ranks: 90u, start: 10u, xp: 0u);
Assert.Equal(1, movementStatsUpdated);
Assert.Equal(0f, character.MovementSkills.Burden, precision: 4);
// EncumbranceVal (property 5) = 7500 -> load = 7500/15000 = 0.5.
var props = new PropertyBundle();
props.Ints[(uint)PropertyInt.EncumbranceVal] = 7500;
objects.UpsertProperties(playerGuid, props);
Assert.True(movementStatsUpdated >= 2);
Assert.Equal(0.5f, character.MovementSkills.Burden, precision: 4);
router.Dispose();
}
[Fact]
public void StaminaVitalChange_PushesCurrentStamina()
{
using var session = NewSession();
var character = new RuntimeCharacterState();
int movementStatsUpdated = 0;
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
NewInventoryBindings(),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d,
OnMovementStatsUpdated: () => movementStatsUpdated++),
NewSocialBindings());
router.Attach();
Assert.Equal(-1, character.MovementSkills.CurrentStamina);
character.LocalPlayer.OnVitalUpdate(
vitalId: 8u, ranks: 40u, start: 20u, xp: 0u, current: 45u);
Assert.Equal(1, movementStatsUpdated);
Assert.Equal(45, character.MovementSkills.CurrentStamina);
router.Dispose();
}
private static LiveEntitySessionSink NoOpEntitySink() => new(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => { },
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { });
private static LiveEnvironmentSessionSink NoOpEnvironmentSink() => new(
EnvironChanged: _ => { },
ServerTimeUpdated: _ => { });
private static LiveSessionEventRouter NewRouter( private static LiveSessionEventRouter NewRouter(
WorldSession session, WorldSession session,
Counters counters, Counters counters,