diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3bf18d91..525f09b7 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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 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 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-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-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-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 | @@ -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-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-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 | |---|---|---|---|---|---| | 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-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-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 | @@ -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-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-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 11–15) + 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-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. 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-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-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. **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), and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). diff --git a/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md new file mode 100644 index 00000000..fa9d1f35 --- /dev/null +++ b/docs/research/2026-07-30-stat-coupled-movement-pseudocode.md @@ -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 = // "load < 2.0" per BN's own + // asserted C0 subexpression + if (!p) return 1 + return 0 +``` + +Literal BN reading: `if (!p) return 1` = "if load is NOT < 2.0 (i.e. >= 2.0), +return CAN-jump; otherwise CANNOT". That is backwards from every other +retail-movement fact we have (LoadMod's own floor sits at 2.0; the campaign's +connected-matrix acceptance is "≥200% barely moves/jumps", not "can only jump +when overloaded"). This is the documented BN "bitfield mush" artifact class +(`feedback_bn_decomp_field_names.md`) — the flag-synthesis is unreliable for +the FOLLOWING `test ah,mask` interpretation even when the preceding +subexpression is trustworthy. + +**Resolution (register row UN-8, see §6):** `CanJump` returns `load < 2.0` +(can jump under 200% burden; refused at/above it) — the polarity a normal +AC player's lived experience requires, and the one that makes CanJump's own +threshold coincide with `LoadMod`'s floor. ACE gives no tiebreaker +(`WeenieObject.CanJump` is an unconditional `return true` stub — never +ported burden gating at all). Ghidra MCP was down for this slice; flagged +for a future confirmation pass, not blocking this port. + +## 4. JumpStaminaCost (0x00591b90, pc 412949) — FULLY READABLE + +```c +CACQualities::JumpStaminaCost(this, extent, &costOut): + load = 0 + if (InqLoad(this, &load) == 0) return 0 + pk = 0 + pkStatus = InqInt(this, PROPERTY_INT_PLAYER_KILLER_STATUS=0x86, default=8) + if (pkStatus == 4 || pkStatus == 0x40): // PK / PKLite + pkTimestamp = InqFloat(this, PROPERTY_FLOAT_LAST_PK_ATTACK_TIMESTAMP=0x91) + if (pkTimestamp is present && !(pkTimestamp + 20.0 < Timer::cur_time)): + pk = 1 // PK timer active (<20s since last PK act) + *costOut = MovementSystem::JumpStaminaCost(extent, load, pk) + return 1 // ALWAYS true once InqLoad succeeds — no affordability + // check lives in this function. +``` + +**Key finding:** retail's `CanQualities::JumpStaminaCost` NEVER returns false +(except when `InqLoad` itself fails, which doesn't happen for a real player). +`jump_is_allowed`'s `if (!WeenieObj.JumpStaminaCost(...)) return 0x47` branch +(the "refusal" path our own `MotionInterpreter.cs` already ports verbatim, +W0-pins.md A2) is real retail *machinery*, but `CACQualities` never actually +exercises the refusing side of it. **"Refused jump" does not happen via this +mechanism in retail — only "weak jump" (see §5).** P1 ports +`JumpStaminaCost` to always return `true` with the REAL computed cost +(retiring the TS-5 zero-cost stub), matching this decomp exactly. + +The `pk` flag is `PlayerKillerStatus`/`LastPkAttackTimestamp` — TS-23's +exact scope (P3, not P1). P1 hardcodes `pk: false` at the one new call site +(`PlayerWeenie.JumpStaminaCost`) and documents the dependency against TS-23 +rather than re-implementing PK parsing here. + +## 5. InqRunRate (0x00592800, pc 413824) / InqJumpVelocity (0x00592980, pc 413902) — FULLY READABLE + +Both functions share one shape (Run uses skill id 0x18=24, Jump uses 0x16=22): + +```c +InqRunRate(this, &rateOut): + load = 1.0 + if (InqLoad(this, &load) == 0) return 0 + + currentStamina = 0 + if (AttributeCache::InqAttribute2nd(attribCache, ATTR2ND_STAMINA=4, ¤tStamina) == 0) + return 0 + EnchantAttribute2nd(this, 4, ¤tStamina) // vital-buff adjusts the LOCAL COPY only + // (not the wire "current stamina" state) + + skill = InqSkillBaseLevel(this, SKILL_RUN=0x18) // base: formula-bonus + init + ranks + // (+ two minor bonus properties 0x146, + // 0x158 — NOT ported, see §6 AP-127) + EnchantSkill(this, 0x18, &skill) // vitae * skill-enchantments, floor@0.5, round + + if (currentStamina == 0) skill = 0 // THE stamina-gates-movement mechanism + + *rateOut = MovementSystem::GetRunRate(load, skill, 1.0) + return 1 +``` + +`InqJumpVelocity` is identical but for skill id 0x16=22, and finishes with +`sqrt(MovementSystem::GetJumpHeight(load, skill, extent, 1.0) * 19.6)` (pc +413975, matching `GetJumpVZ`'s existing sqrt call already in +`MotionInterpreter.cs`/`PlayerWeenie.cs` — unchanged). + +**Answering the plan's question — "which skill level does retail feed?"** +Neither raw base nor a separately-cached value: retail re-derives, on every +query, `EnchantSkill(baseSkill)` where `EnchantSkill` (`CEnchantmentRegistry:: +EnchantSkill` 0x005947b0, pc 416240, FULLY READABLE) is: + +```c +EnchantSkill(registry, skillId, &valueInOut): + value = *valueInOut // base skill (formulaBonus+init+ranks) + if (registry._vitae != null): + value = Enchant(registry._vitae, value) // vitae multiplier FIRST + matching = CullEnchantmentsFromList(mult_list, category=SKILL=0x10, skillId) + ++ CullEnchantmentsFromList(add_list, category=SKILL=0x10, skillId) + for each e in matching: value = Enchant(e, value) // per-record mult OR add + if (value < 0.5) value = 0 // floor + *valueInOut = (int)value // truncate (ftol2) + return ... +``` + +`CEnchantmentRegistry::EnchantAttribute2nd` (0x00594670, pc 416169, the +vitals path our `EnchantmentMath.GetMod` already ports for +`LocalPlayerState.GetMaxApprox`) applies `_vitae` in the **identical** +position (first, before the mult/add lists) — confirming our existing vitae +representation (`ActiveEnchantmentRecord.Bucket == 4`, a StatModType `Vitae` +flag `0x00800000` classified in `GameEventWiring.ClassifyLiveEnchantmentBucket`) +is the right vehicle: **P1 reuses it unmodified**, adding a sibling +`EnchantmentMath.GetSkillMod` (filtered by `StatModType & Skill(0x10) != 0` +instead of the vitals' implicit attribute2nd filter) rather than inventing a +new vitae channel. This satisfies "vitae/enchant-adjusted effective run/jump +skill... reading vitae + relevant skill enchantments from the M3 +active-effect state" without a general effective-skill engine — the only new +code is the type-flag filter and the skill-id key. + +**Two things P1 deliberately does NOT port** (bounded scope, register row +AP-127): +1. Two minor additive skill-bonus properties inside `InqSkillBaseLevel`'s + surrounding block (property `0x146` "> 0 → +5", property `0x158` + "specialized-skill → double a PP-derived term") — small, rare bonuses + unrelated to burden/stamina/vitae. +2. `EnchantAttribute2nd`'s buff-adjustment of the LOCAL stamina-current copy + used only for the `== 0` gate (i.e. a Stamina-boosting buff could + theoretically keep that local copy above 0 even at true-zero wire + stamina). We gate on the raw wire "current stamina" value directly. + +## 6. GetRunRate / GetJumpHeight / JumpStaminaCost formula bodies (MovementSystem, pc 695958+) + +`GetRunRate` (0x006b0950) and the `arg3!=0` (PK) branch of `JumpStaminaCost` +(0x006b0a40) have their GENERAL-CASE arithmetic entirely dropped by BN (only +the `EncumbranceSystem::LoadMod`/`800`-skill-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. diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index b51a2183..f4e946a2 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -275,25 +275,45 @@ internal sealed class LiveSessionRuntimeFactory _domain.Actions.Combat, _domain.Character, ResolveSkillFormulaBonus: skillCreditResolver.Resolve, - OnSkillsUpdated: (runSkill, jumpSkill) => - { - 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}"); - } - }, + OnSkillsUpdated: (runSkill, jumpSkill) => ApplyMovementStats("skills"), OnConfirmationRequest: request => _ui.RetailUi?.HandleConfirmationRequest(request), OnConfirmationDone: 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")); + } + + /// + /// Re-applies the current + /// snapshot (skills/burden/stamina) to the live player controller and + /// forces an immediate movement re-evaluation via + /// MotionInterpreter.ReportExhaustion — the retail + /// CMotionInterp::ReportExhaustion dual-dispatch re-apply, now + /// wired to a real consumer (Campaign P Slice P1). + /// + 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( diff --git a/src/AcDream.Core/Physics/EncumbranceSystem.cs b/src/AcDream.Core/Physics/EncumbranceSystem.cs new file mode 100644 index 00000000..5ac28c5d --- /dev/null +++ b/src/AcDream.Core/Physics/EncumbranceSystem.cs @@ -0,0 +1,43 @@ +namespace AcDream.Core.Physics; + +/// +/// Retail EncumbranceSystem (named-retail decomp +/// docs/research/named-retail/acclient_2013_pseudo_c.txt pc 256393), +/// the pure load/capacity math consumed by and +/// 's InqLoad-equivalent chain. +/// +/// +/// These are the SAME three formulas AcDream.Core.Items.BurdenMath +/// 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 +/// for citation clarity, but a single formula +/// implementation keeps the burden HUD and movement physics from drifting +/// apart. See docs/research/2026-07-30-stat-coupled-movement-pseudocode.md +/// §2 and §6. +/// +/// +public static class EncumbranceSystem +{ + /// + /// EncumbranceSystem::EncumbranceCapacity 0x004fcc00: + /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength. + /// + public static int EncumbranceCapacity(int strength, int aug) => + AcDream.Core.Items.BurdenMath.EncumbranceCapacity(strength, aug); + + /// + /// EncumbranceSystem::Load 0x004fcc40: burden / capacity + /// (1.0 = at capacity). Returns 0 when capacity <= 0 (no-data). + /// + public static float Load(int capacity, int burden) => + AcDream.Core.Items.BurdenMath.LoadRatio(capacity, burden); + + /// + /// EncumbranceSystem::LoadMod 0x004fcc70: full effectiveness + /// through 100% load, linear falloff to zero at 200%, zero above it. + /// + public static float LoadMod(float load) => + AcDream.Core.Items.BurdenMath.LoadModifier(load); +} diff --git a/src/AcDream.Core/Physics/MovementSystem.cs b/src/AcDream.Core/Physics/MovementSystem.cs new file mode 100644 index 00000000..7241bc2b --- /dev/null +++ b/src/AcDream.Core/Physics/MovementSystem.cs @@ -0,0 +1,98 @@ +using System; + +namespace AcDream.Core.Physics; + +/// +/// Retail MovementSystem (named-retail decomp pc 695958+): the pure +/// formula layer PlayerWeenie (the CACQualities-shaped composition) +/// calls once burden, skill, and stamina inputs are assembled. See +/// docs/research/2026-07-30-stat-coupled-movement-pseudocode.md §6. +/// +/// +/// x87-mush disclosure: 's general-case +/// arithmetic and 's pk!=0 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 +/// references/ACE/Source/ACE.Server/Physics/Animation/MovementSystem.cs, +/// which already matched this exact acdream port's PRE-P1 code +/// (PlayerWeenie.GetRunRate/GetJumpHeight 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 +/// scaling parameters (every known call site passes 1f) are new. +/// +/// +public static class MovementSystem +{ + /// + /// MovementSystem::GetRunRate 0x006b0950. Retail-verified 800-skill + /// cap (InqMaxRunRate passes skill=9999 to reach this cap); general + /// case ACE-cross-referenced (BN dropped the arithmetic, see class doc). + /// + 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; + } + + /// + /// MovementSystem::GetJumpHeight 0x006b09b0. Fully readable except + /// the extent-clamp micro-branch (x87 mush, ACE's Math.Clamp(power,0,1) + /// is the tiebreaker — matches the pre-P1 acdream port unchanged). + /// + 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; + } + + /// + /// MovementSystem::JumpStaminaCost 0x006b0a40. The pk==0 + /// branch is fully readable: ceil((load + 0.5) * power * 8 + 2) — + /// note load (not power) carries the +0.5; the + /// campaign plan's shorthand had the operands swapped (see pseudocode + /// doc §6). The pk!=0 branch is entirely dropped by BN; ACE's + /// (power+1.0)*100.0 is the tiebreaker. is + /// hardcoded false at every P1 call site pending TS-23 + /// (PlayerKillerStatus parsing, Campaign P Slice P3) — ported here for + /// signature completeness only. + /// + 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); + } + + /// + /// MovementSystem::GetJumpPower — the algebraic inverse of + /// , 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 + /// CMotionInterp (0x0056afac, out of R3/P1 scope — see + /// ChargeJump's doc comment in MotionInterpreter.cs). 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. + /// + 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); + } +} diff --git a/src/AcDream.Core/Physics/PlayerWeenie.cs b/src/AcDream.Core/Physics/PlayerWeenie.cs index 3d1ab62d..fb53eb28 100644 --- a/src/AcDream.Core/Physics/PlayerWeenie.cs +++ b/src/AcDream.Core/Physics/PlayerWeenie.cs @@ -1,18 +1,56 @@ namespace AcDream.Core.Physics; /// -/// IWeenieObject implementation for the local player. Provides skill-based -/// run rate and jump velocity calculations. +/// IWeenieObject implementation for the local player — the C# analogue of +/// retail's CACQualities "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 IsThePlayer() — pc 406512+, confirming this +/// query family only ever reaches the LOCAL player's weenie). /// -/// Formulas from decompiled acclient.exe, cross-referenced against -/// 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 docs/research/2026-07-30-stat-coupled-movement-pseudocode.md. +/// _runSkill/_jumpSkill arrive ALREADY EnchantSkill-adjusted +/// (vitae + skill enchantments folded in by +/// AcDream.Runtime.Gameplay.RuntimeCharacterState.RecomputeMovementSkills) +/// — this class stays a pure formula consumer with no Spellbook/enchantment +/// dependency, matching retail's OWN split between CACQualities (the +/// query surface) and CEnchantmentRegistry (the buff aggregator it +/// calls into internally). +/// +/// +/// +/// Formulas from decompiled acclient.exe ( / +/// ), cross-referenced against ACE +/// MovementSystem.GetRunRate/GetJumpHeight. +/// /// public sealed class PlayerWeenie : IWeenieObject { + /// + /// Retail CACQualities::CanJump'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 's own floor. + /// + public const float CanJumpLoadThreshold = 2.0f; + private int _runSkill; private int _jumpSkill; private float _burden; + /// + /// Retail AttributeCache::InqAttribute2nd(ATTR2ND_STAMINA=4)'s + /// current-stamina reading, consulted by InqRunRate/ + /// InqJumpVelocity to zero the effective skill when exhausted + /// (pc 413824-413898, 413902-413979). null = never pushed + /// (matches every pre-P1 caller/test — no gating, today's behavior + /// unchanged); any non-negative value including 0 is a real reading. + /// + private uint? _currentStamina; + public PlayerWeenie(int runSkill = 0, int jumpSkill = 0, float burden = 0f) { _runSkill = runSkill; @@ -26,74 +64,91 @@ public sealed class PlayerWeenie : IWeenieObject _jumpSkill = jumpSkill; } + /// + /// Pushes the retail InqLoad-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 IndicatorBarController.UpdateBurden already + /// assembles) and pushes it — see the pseudocode doc §9. + /// public void SetBurden(float burden) => _burden = burden; + /// + /// Pushes the current-stamina reading feeding the zero-skill gate in + /// /. Pass + /// null to return to "unknown, don't gate" (matches construction + /// default). + /// + public void SetStamina(uint? currentStamina) => _currentStamina = currentStamina; + public bool InqRunRate(out float rate) { - rate = GetRunRate(_burden, _runSkill); + int effectiveSkill = _currentStamina == 0 ? 0 : _runSkill; + rate = MovementSystem.GetRunRate(_burden, effectiveSkill); return true; } 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); return true; } - public bool CanJump(float extent) => true; // burden/stamina checks deferred + /// + /// Retail CACQualities::CanJump (0x00591b50): refuses only past + /// (200% load) — see the class doc's + /// UN-8 note. TS-5 retired: this was previously an unconditional + /// true. + /// + public bool CanJump(float extent) => _burden < CanJumpLoadThreshold; /// /// R3-W3 (W0-pins.md A3): the local player's weenie is THE player. /// Feeds W4's apply_current_movement/ReportExhaustion - /// dual-dispatch gate — no consumer yet in W3. + /// dual-dispatch gate. /// public bool IsThePlayer() => true; /// - /// TS-5 (extended): stamina cost gating deferred pending stat plumbing — - /// always affordable, cost 0. Matches 's existing - /// always-true stance. + /// Retail CACQualities::JumpStaminaCost (0x00591b90, pc 412949, + /// FULLY READABLE): computes the real cost via + /// and returns true + /// 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. pk is hardcoded false + /// pending TS-23 (PlayerKillerStatus parsing, Campaign P Slice P3). /// public bool JumpStaminaCost(float extent, out int cost) { - cost = 0; + cost = MovementSystem.JumpStaminaCost(extent, _burden, pk: false); return true; } /// - /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4 - /// Capped at 4.5 when runSkill >= 800. - /// Source: decompiled + ACE MovementSystem.GetRunRate + /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4. + /// Capped at 4.5 when runSkill >= 800. Thin forwarder to + /// — kept for source + /// compatibility with existing golden-value tests. /// - public static float GetRunRate(float burden, int runSkill) - { - if (runSkill >= 800) return 18f / 4f; - float loadMod = GetBurdenMod(burden); - return (loadMod * ((float)runSkill / (runSkill + 200) * 11f) + 4f) / 4f; - } + public static float GetRunRate(float burden, int runSkill) => + MovementSystem.GetRunRate(burden, runSkill); /// - /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent - /// Clamped to minimum 0.35m. - /// Source: decompiled + ACE MovementSystem.GetJumpHeight + /// JumpHeight = burdenMod * (jumpSkill / (jumpSkill + 1300) * 22.2 + 0.05) * extent, + /// clamped to minimum 0.35m. Thin forwarder to + /// — kept for source + /// compatibility with existing golden-value tests. /// - public static float GetJumpHeight(float burden, int jumpSkill, float 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); - } + public static float GetJumpHeight(float burden, int jumpSkill, float extent) => + MovementSystem.GetJumpHeight(burden, jumpSkill, extent); /// - /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at 200%. - /// Source: decompiled + ACE EncumbranceSystem.GetBurdenMod + /// Encumbrance modifier: 1.0 when unloaded, linearly decreasing to 0 at + /// 200%. Thin forwarder to — + /// kept for source compatibility with existing golden-value tests. /// - public static float GetBurdenMod(float burden) - { - if (burden < 1f) return 1f; - if (burden < 2f) return 2f - burden; - return 0f; - } + public static float GetBurdenMod(float burden) => EncumbranceSystem.LoadMod(burden); } diff --git a/src/AcDream.Core/Spells/EnchantmentMath.cs b/src/AcDream.Core/Spells/EnchantmentMath.cs index 6fd27eee..160ad228 100644 --- a/src/AcDream.Core/Spells/EnchantmentMath.cs +++ b/src/AcDream.Core/Spells/EnchantmentMath.cs @@ -76,11 +76,20 @@ public static class EnchantmentMath /// (only one buff per wins). /// Target stat key (ACE /// PropertyAttribute2nd enum value: 1=MaxHealth, - /// 3=MaxStamina, 5=MaxMana). + /// 3=MaxStamina, 5=MaxMana — or a Skill id when + /// is + /// ). + /// When set, a record's + /// StatModType 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). null (default) preserves + /// the original vitals behavior with no type check, unchanged from + /// before Campaign P. public static VitalMod GetMod( IEnumerable enchantments, SpellTable table, - uint statKey) + uint statKey, + uint? requiredStatModTypeFlag = null) { // Family-stacking: bucket the active enchantments by Family and // keep the strongest one per bucket (the one with the largest @@ -129,8 +138,20 @@ public static class EnchantmentMath 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 — - // 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; switch (ench.Bucket) { @@ -147,6 +168,35 @@ public static class EnchantmentMath : new VitalMod(multiplier, additive); } + /// + /// Campaign P Slice P1 (2026-07-30) — Skill-namespace convenience over + /// , matching retail CEnchantmentRegistry:: + /// EnchantSkill (0x005947b0): vitae applies unconditionally (same + /// as vitals), multiplicative/additive skill buffs are filtered to + /// records whose StatModType carries + /// AND whose StatModKey + /// equals (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. + /// + public static VitalMod GetSkillMod( + IEnumerable enchantments, + SpellTable table, + uint skillId) => + GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill); + + /// + /// Retail EnchantmentTypeFlags bits relevant to disambiguating + /// 's statKey namespace (ACE + /// ACE.Entity.Enum.EnchantmentTypeFlags, cross-referenced — + /// StatModType is a bitfield the wire already carries per-enchantment). + /// + public static class EnchantmentTypeFlag + { + public const uint SecondAtt = 0x0000002u; + public const uint Skill = 0x0000010u; + } + /// /// Stat-key constants matching ACE PropertyAttribute2nd /// (verified against docs/research/named-retail/acclient.h diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 90c91da7..88377f46 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -24,6 +24,7 @@ public sealed class Spellbook private readonly Dictionary _activeById = new(); private readonly Dictionary> _enchantmentOrderByBucket = new(); private readonly Dictionary _vitalModCache = new(); + private readonly Dictionary _skillModCache = new(); private readonly List[] _favoriteSpells = Enumerable.Range(0, 8) .Select(_ => new List()).ToArray(); private readonly Dictionary _desiredComponents = new(); @@ -102,6 +103,29 @@ public sealed class Spellbook return calculated; } + /// + /// 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 CEnchantmentRegistry::EnchantSkill + /// (0x005947b0). Mirrors 's caching shape; + /// consumed by AcDream.Runtime.Gameplay.RuntimeCharacterState's + /// run/jump skill recompute. + /// + 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; + } + /// Fires when a spell is added to the player's spellbook. public event Action? SpellLearned; @@ -356,6 +380,7 @@ public sealed class Spellbook _activeById.Clear(); _enchantmentOrderByBucket.Clear(); _vitalModCache.Clear(); + _skillModCache.Clear(); foreach (ActiveEnchantmentRecord enchantment in enchantments) UpsertManifestEnchantment(enchantment); @@ -430,6 +455,7 @@ public sealed class Spellbook _activeById.Clear(); _enchantmentOrderByBucket.Clear(); _vitalModCache.Clear(); + _skillModCache.Clear(); foreach (List tab in _favoriteSpells) tab.Clear(); _desiredComponents.Clear(); _spellbookFilters = 0x3FFFu; @@ -448,6 +474,7 @@ public sealed class Spellbook private void NotifyEnchantmentsChanged() { _vitalModCache.Clear(); + _skillModCache.Clear(); EnchantmentsChanged?.Invoke(); StateChanged?.Invoke(); } diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index dc9a8296..8378933c 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -567,7 +567,13 @@ internal sealed class HeadlessSessionHost : IDisposable OnConfirmationRequest: null, OnConfirmationDone: null, 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( Runtime.CommunicationOwner.Chat, Runtime.CommunicationOwner.TurbineChat, diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index dbf4d75a..86ce190e 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -891,6 +891,30 @@ public sealed class PlayerMovementController _weenie.SetSkills(runSkill, jumpSkill); } + /// + /// Campaign P Slice P1 (2026-07-30): pushes the retail + /// InqLoad-equivalent burden ratio computed by Runtime (Strength + /// + augmentation property 0xE6 + EncumbranceVal property 5) into the + /// player's — wires the previously-dead + /// PlayerWeenie.SetBurden setter (TS-5 retired). + /// + public void SetCharacterBurden(float burden) + { + _weenie.SetBurden(burden); + } + + /// + /// 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 + /// InqRunRate/InqJumpVelocity's effective-skill-zeroing per + /// retail's CACQualities::InqRunRate/InqJumpVelocity. + /// + public void SetCharacterStamina(int currentStamina) + { + _weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina); + } + /// /// R3-W2 (r3-port-plan.md §4): the player's /// — GameWindow binds the player sequencer's MotionDone seam to it so the diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index ed4bed24..4f0715a0 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -43,11 +43,28 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( /// public sealed class RuntimeCharacterState : IDisposable { + /// ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5). + public const uint RunSkillId = 24u; + /// ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5). + public const uint JumpSkillId = 22u; + private bool _disposed; private long _characterRevision; private long _spellbookRevision; private bool _internalSubscriptionsAttached; + /// + /// Campaign P Slice P1 (2026-07-30): the pre-EnchantSkill base + /// run/jump skill (formulaBonus+init+ranks, as parsed from + /// PlayerDescription) — kept so + /// can re-derive the vitae/enchantment-adjusted value purely from a + /// spellbook change, without waiting for a fresh skill push. -1 = + /// unknown (mirrors 's own + /// sentinel convention). + /// + private int _runSkillBase = -1; + private int _jumpSkillBase = -1; + public RuntimeCharacterState(SpellTable? spellTable = null) { Spellbook = new Spellbook(spellTable); @@ -56,6 +73,7 @@ public sealed class RuntimeCharacterState : IDisposable MovementSkills = new RuntimeMovementSkillState(); View = new CharacterView(this); Spellbook.StateChanged += OnSpellbookChanged; + Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement; LocalPlayer.Changed += OnVitalChanged; LocalPlayer.AttributeChanged += OnAttributeChanged; LocalPlayer.CharacterChanged += OnCharacterChanged; @@ -117,9 +135,66 @@ public sealed class RuntimeCharacterState : IDisposable && options.Options2 == RuntimeCharacterOptionsState.DefaultOptions2, MovementSkills.RunSkill == -1 - && MovementSkills.JumpSkill == -1); + && MovementSkills.JumpSkill == -1 + && MovementSkills.Burden == 0f + && MovementSkills.CurrentStamina == -1 + && _runSkillBase == -1 + && _jumpSkillBase == -1); } + /// + /// Campaign P Slice P1 (2026-07-30): stores the pre-EnchantSkill + /// base run/jump skill (PlayerDescription's formulaBonus+init+ranks) + /// and pushes the vitae/enchantment-adjusted result into + /// — the SAME call shape + /// LiveSessionEventRouter's pre-P1 onSkillsUpdated callback + /// already used (MovementSkills.Update(runSkill, jumpSkill)), now + /// routed through the retail CEnchantmentRegistry::EnchantSkill + /// chain. A value < 0 leaves that half's base untouched (matches + /// 's own "don't touch" + /// convention for a missing half). + /// + public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (runSkillBase >= 0) _runSkillBase = runSkillBase; + if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase; + RecomputeMovementSkills(); + } + + /// + /// Re-derives the adjusted run/jump skill from the stored base plus the + /// CURRENT spellbook state (vitae + skill enchantments) — matching + /// retail's CEnchantmentRegistry::EnchantSkill (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 + /// notification (a vitae/buff change alone must move the produced rate + /// without a fresh PlayerDescription). + /// + 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(); + /// /// Installs immutable DAT metadata without transferring its ownership to /// 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(LocalPlayer.Clear, ref failures); Try(Options.ResetSession, ref failures); + _runSkillBase = -1; + _jumpSkillBase = -1; Try(MovementSkills.ResetSession, ref failures); if (failures is not null) { @@ -263,11 +340,14 @@ public sealed class RuntimeCharacterState : IDisposable Try(Spellbook.Clear, ref failures); Try(LocalPlayer.Clear, ref failures); Try(Options.ResetSession, ref failures); + _runSkillBase = -1; + _jumpSkillBase = -1; Try(MovementSkills.ResetSession, ref failures); } finally { Spellbook.StateChanged -= OnSpellbookChanged; + Spellbook.EnchantmentsChanged -= OnEnchantmentsChangedForMovement; LocalPlayer.Changed -= OnVitalChanged; LocalPlayer.AttributeChanged -= OnAttributeChanged; LocalPlayer.CharacterChanged -= OnCharacterChanged; @@ -467,28 +547,42 @@ public sealed class RuntimeCharacterOptionsState public readonly record struct RuntimeMovementSkillSnapshot( int RunSkill, 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; } /// -/// Server-authoritative run/jump values retained independently of any -/// graphical movement controller. App applies this borrowed state whenever -/// its presentation/physics controller exists or is rebuilt. +/// Server-authoritative run/jump/burden/stamina values retained +/// independently of any graphical movement controller. App applies this +/// 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 +/// . /// public sealed class RuntimeMovementSkillState { private int _runSkill = -1; private int _jumpSkill = -1; + private float _burden; + private int _currentStamina = -1; private long _revision; public int RunSkill => Volatile.Read(ref _runSkill); 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 long Revision => Interlocked.Read(ref _revision); public RuntimeMovementSkillSnapshot Snapshot => - new(_runSkill, _jumpSkill, Revision); + new(_runSkill, _jumpSkill, Revision, _burden, _currentStamina); public void Update(int runSkill, int jumpSkill) { @@ -507,10 +601,37 @@ public sealed class RuntimeMovementSkillState Interlocked.Increment(ref _revision); } + /// + /// Pushes a fresh retail InqLoad-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. + /// + public void UpdateBurden(float burden) + { + if (Burden == burden) return; + Volatile.Write(ref _burden, burden); + Interlocked.Increment(ref _revision); + } + + /// + /// 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. + /// + public void UpdateStamina(int currentStamina) + { + if (CurrentStamina == currentStamina) return; + Volatile.Write(ref _currentStamina, currentStamina); + Interlocked.Increment(ref _revision); + } + public void ResetSession() { Volatile.Write(ref _runSkill, -1); Volatile.Write(ref _jumpSkill, -1); + Volatile.Write(ref _burden, 0f); + Volatile.Write(ref _currentStamina, -1); Interlocked.Increment(ref _revision); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs index 75ab1300..a94f4f1b 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeMovementSkillProjection.cs @@ -19,6 +19,10 @@ public static class RuntimeMovementSkillProjection controller.SetCharacterSkills( snapshot.RunSkill, 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; } } diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 90ea6cd4..1d3f025f 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -3,7 +3,9 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Runtime.Gameplay; @@ -44,7 +46,14 @@ public sealed record LiveCharacterSessionBindings( Action? OnSkillsUpdated, Action? OnConfirmationRequest, Action? OnConfirmationDone, - Func? ClientTime); + Func? 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( ChatLog Chat, @@ -153,10 +162,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting social.TurbineChat, 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, jumpSkill); character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill); + character.OnMovementStatsUpdated?.Invoke(); }, resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, onShortcuts: inventory.OnShortcuts, @@ -174,6 +188,52 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting externalContainers: inventory.ExternalContainers, accepting: IsAccepting)); 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( + h => inventory.Objects.ObjectAdded += h, + h => inventory.Objects.ObjectAdded -= h, + () => RecomputeBurden(inventory, character)); + SubscribeToRecompute( + h => inventory.Objects.ObjectUpdated += h, + h => inventory.Objects.ObjectUpdated -= h, + () => RecomputeBurden(inventory, character)); + SubscribeToRecompute( + h => inventory.Objects.ObjectRemoved += h, + h => inventory.Objects.ObjectRemoved -= h, + () => RecomputeBurden(inventory, character)); + SubscribeToRecompute( + h => inventory.Objects.ObjectMoved += h, + h => inventory.Objects.ObjectMoved -= h, + () => RecomputeBurden(inventory, character)); + SubscribeToRecompute( + 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( + 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( + h => character.Character.LocalPlayer.Changed += h, + h => character.Character.LocalPlayer.Changed -= h, + kind => RecomputeStamina(kind, character)); + _subscriptions.Add(new CombatChatTranslator( character.Combat, social.Chat, @@ -259,6 +319,89 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting ConstructionCheckpoint(); } + /// + /// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY + /// job is "something relevant changed, recompute" — thin wrapper over + /// that discards the payload. + /// + private void SubscribeToRecompute( + Action> attach, + Action> detach, + Action recompute) => + Subscribe(attach, detach, (T _) => recompute()); + + /// + /// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of + /// (ClientObjectTable.Cleared carries + /// no payload). + /// + private void SubscribeParameterless( + Action attach, + Action detach, + Action sink) + { + Action handler = () => + { + if (Volatile.Read(ref _accepting) != 0) + sink(); + }; + + attach(handler); + _subscriptions.Add(() => detach(handler)); + + ConstructionCheckpoint(); + } + + /// + /// Campaign P Slice P1 (2026-07-30): retail CACQualities::InqLoad + /// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal + /// property 5, falling back to the summed carried burden) — the SAME + /// input assembly IndicatorBarController.UpdateBurden / + /// InventoryController.RefreshBurden already use for the burden + /// HUD. See the pseudocode doc §2/§9. + /// + 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(); + } + + /// + /// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital + /// changes into — feeds + /// CACQualities::InqRunRate/InqJumpVelocity's stamina==0 + /// effective-skill-zeroing gate (pseudocode doc §5). + /// + 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() => _constructionCheckpoint?.Invoke(++_constructionStep); diff --git a/tests/AcDream.Core.Tests/Physics/EncumbranceSystemTests.cs b/tests/AcDream.Core.Tests/Physics/EncumbranceSystemTests.cs new file mode 100644 index 00000000..99229a49 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/EncumbranceSystemTests.cs @@ -0,0 +1,59 @@ +using AcDream.Core.Items; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Retail EncumbranceSystem (0x004fcc00/40/70, Campaign P Slice P1) — +/// cross-checked 1:1 against the existing +/// implementation it delegates to (same formulas, same addresses). +/// +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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/MovementSystemTests.cs b/tests/AcDream.Core.Tests/Physics/MovementSystemTests.cs new file mode 100644 index 00000000..cd9e6773 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/MovementSystemTests.cs @@ -0,0 +1,129 @@ +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Golden-value tables for retail MovementSystem +/// (docs/research/2026-07-30-stat-coupled-movement-pseudocode.md §6), +/// Campaign P Slice P1. +/// +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); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs b/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs index 34302274..a4821d7f 100644 --- a/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PlayerWeenieTests.cs @@ -81,4 +81,114 @@ public class PlayerWeenieTests Assert.Equal(0.5f, PlayerWeenie.GetBurdenMod(1.5f), 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); + } } diff --git a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs index 08f6c574..b9597294 100644 --- a/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs +++ b/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs @@ -228,6 +228,108 @@ public sealed class EnchantmentMathTests 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); + // ── 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(), 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) { // Build a synthetic CSV with just enough columns for SpellTable to diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index fdebf39b..492c18e1 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -826,4 +826,39 @@ public class PlayerMovementControllerTests Assert.False(controller.IsAirborne, "Player should have landed"); 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); + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index ffce2c8d..eba17774 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -193,6 +193,127 @@ public sealed class RuntimeCharacterStateTests 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] public void CharacterViewBorrowsExactOwnersWithoutReconstructedState() { diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index 984bc907..38090629 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -5,6 +5,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Social; using AcDream.Core.Spells; using AcDream.Runtime.Session; @@ -180,6 +181,111 @@ public sealed class LiveSessionEventRouterTests 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( WorldSession session, Counters counters,